Ajout de drag'n drop là ou c'est possible ; ajout de l'import des ennemis depuis foundryVTT
All checks were successful
All checks were successful
This commit is contained in:
@@ -11,7 +11,7 @@
|
||||
|
||||
<!-- Étape 1 : upload (masqué une fois en revue) -->
|
||||
@if (!reviewing) {
|
||||
<section class="upload-area">
|
||||
<section class="upload-area" appFileDrop [dropDisabled]="importing" (filesDropped)="onPdfDropped($event)">
|
||||
<input #pdfInput type="file" accept="application/pdf,.pdf" hidden (change)="onPdfSelected($event)" />
|
||||
<button type="button" class="btn-primary big" [disabled]="importing" (click)="pdfInput.click()">
|
||||
<lucide-icon [img]="Upload" [size]="16"></lucide-icon>
|
||||
|
||||
@@ -32,6 +32,9 @@
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
background: #0b1220;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
|
||||
&.drag-over { border-color: #667eea; background: #141d33; }
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
|
||||
@@ -20,6 +20,7 @@ import { CampaignImportProposal } from '../../../services/campaign-import.model'
|
||||
import { loadCampaignTreeData, CampaignTreeData } from '../../campaign-tree.helper';
|
||||
import { of } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { FileDropDirective } from '../../../shared/file-drop.directive';
|
||||
|
||||
/**
|
||||
* Nœuds éditables (= proposition + état d'UI). `existing` = déjà présent dans la
|
||||
@@ -54,7 +55,7 @@ interface NpcNode { name: string; description: string; selected: boolean; existi
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-campaign-import',
|
||||
imports: [FormsModule, LucideAngularModule, TranslatePipe],
|
||||
imports: [FormsModule, LucideAngularModule, TranslatePipe, FileDropDirective],
|
||||
templateUrl: './campaign-import.component.html',
|
||||
styleUrls: ['./campaign-import.component.scss']
|
||||
})
|
||||
@@ -135,6 +136,11 @@ export class CampaignImportComponent implements OnInit {
|
||||
if (file) this.importPdf(file);
|
||||
}
|
||||
|
||||
/** Drop d'un PDF sur la zone d'upload. */
|
||||
onPdfDropped(files: File[]): void {
|
||||
if (!this.importing && files[0]) this.importPdf(files[0]);
|
||||
}
|
||||
|
||||
private importPdf(file: File): void {
|
||||
this.importing = true;
|
||||
this.reviewing = false;
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
<div class="sbl-page">
|
||||
<div class="sbl-toolbar">
|
||||
<div class="sbl-toolbar" appFileDrop [dropDisabled]="importing" (filesDropped)="onMonstersDropped($event)">
|
||||
<button class="btn-back" (click)="back()">
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> {{ 'common.back' | translate }}
|
||||
</button>
|
||||
<span class="spacer"></span>
|
||||
<button class="btn-back" (click)="importMonsters()" [disabled]="importing">
|
||||
<lucide-icon [img]="Upload" [size]="14"></lucide-icon>
|
||||
{{ (importing ? 'enemyList.importing' : 'enemyList.importMonsters') | translate }}
|
||||
</button>
|
||||
<button class="btn-create" (click)="create()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon> {{ 'enemyList.create' | translate }}
|
||||
</button>
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
.sbl-toolbar {
|
||||
display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem;
|
||||
border: 1px dashed transparent; border-radius: 6px;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
&.drag-over { border-color: #667eea; background: rgba(102,126,234,0.12); }
|
||||
.spacer { flex: 1; }
|
||||
button {
|
||||
display: inline-flex; align-items: center; gap: 0.35rem;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { LucideAngularModule, ArrowLeft, Plus, Trash2, Skull, Folder } from 'lucide-angular';
|
||||
import { LucideAngularModule, ArrowLeft, Plus, Trash2, Skull, Folder, Upload } from 'lucide-angular';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { EnemyService } from '../../../services/enemy.service';
|
||||
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
|
||||
import { Enemy } from '../../../services/enemy.model';
|
||||
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||
import { FileDropDirective } from '../../../shared/file-drop.directive';
|
||||
|
||||
/** Groupe d'affichage : un dossier (« Démons »…) et ses ennemis. */
|
||||
interface FolderGroup {
|
||||
@@ -20,7 +21,7 @@ interface FolderGroup {
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-enemy-list',
|
||||
imports: [LucideAngularModule, TranslatePipe],
|
||||
imports: [LucideAngularModule, TranslatePipe, FileDropDirective],
|
||||
templateUrl: './enemy-list.component.html',
|
||||
styleUrls: ['./enemy-list.component.scss']
|
||||
})
|
||||
@@ -30,6 +31,10 @@ export class EnemyListComponent implements OnInit {
|
||||
readonly Trash2 = Trash2;
|
||||
readonly Skull = Skull;
|
||||
readonly Folder = Folder;
|
||||
readonly Upload = Upload;
|
||||
|
||||
/** Import de monstres Foundry en cours (anti double-clic). */
|
||||
importing = false;
|
||||
|
||||
campaignId = '';
|
||||
/** Groupes triés par nom de dossier ; les non-classés en dernier (folder = ''). */
|
||||
@@ -86,6 +91,45 @@ export class EnemyListComponent implements OnInit {
|
||||
this.router.navigate(['/campaigns', this.campaignId, 'enemies', 'create']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Importe un catalogue de monstres Foundry (.json exporté par le module) dans
|
||||
* le bestiaire. Upsert côté backend (dédup par référence).
|
||||
*/
|
||||
importMonsters(): void {
|
||||
if (this.importing) return;
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.json,application/json';
|
||||
input.addEventListener('change', () => {
|
||||
const file = input.files?.[0];
|
||||
if (file) this.importMonsterFile(file);
|
||||
}, { once: true });
|
||||
input.click();
|
||||
}
|
||||
|
||||
/** Drop d'un catalogue de monstres (.json) sur la barre d'outils. */
|
||||
onMonstersDropped(files: File[]): void {
|
||||
if (files[0]) this.importMonsterFile(files[0]);
|
||||
}
|
||||
|
||||
private importMonsterFile(file: File): void {
|
||||
if (this.importing) return;
|
||||
file.text().then(txt => {
|
||||
let catalog: unknown;
|
||||
try {
|
||||
catalog = JSON.parse(txt);
|
||||
} catch {
|
||||
console.error('Catalogue de monstres illisible (JSON invalide)');
|
||||
return;
|
||||
}
|
||||
this.importing = true;
|
||||
this.service.importFoundryMonsters(this.campaignId, catalog).subscribe({
|
||||
next: () => { this.importing = false; this.load(); },
|
||||
error: (err) => { this.importing = false; console.error('Échec import monstres', err); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
open(e: Enemy): void {
|
||||
this.router.navigate(['/campaigns', this.campaignId, 'enemies', e.id]);
|
||||
}
|
||||
|
||||
@@ -43,7 +43,9 @@
|
||||
<label>{{ 'sceneEdit.battlemapLabel' | translate }}</label>
|
||||
<small class="field-hint">{{ 'sceneEdit.battlemapHint' | translate }}</small>
|
||||
|
||||
<div class="battlemap-slot">
|
||||
<div class="battlemap-slot"
|
||||
appFileDrop [dropDisabled]="battlemapUploadingMedia"
|
||||
(filesDropped)="onBattlemapMediaDropped($event)">
|
||||
<span class="battlemap-slot-label">{{ 'sceneEdit.battlemapMedia' | translate }}</span>
|
||||
@if (battlemapMediaFileId) {
|
||||
<span class="battlemap-file">{{ battlemapMediaName || ('sceneEdit.battlemapAttached' | translate) }}</span>
|
||||
@@ -58,7 +60,9 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="battlemap-slot">
|
||||
<div class="battlemap-slot"
|
||||
appFileDrop [dropDisabled]="battlemapUploadingData"
|
||||
(filesDropped)="onBattlemapDataDropped($event)">
|
||||
<span class="battlemap-slot-label">{{ 'sceneEdit.battlemapData' | translate }}</span>
|
||||
@if (battlemapDataFileId) {
|
||||
<span class="battlemap-file">{{ battlemapDataName || ('sceneEdit.battlemapAttached' | translate) }}</span>
|
||||
|
||||
@@ -8,7 +8,15 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0;
|
||||
padding: 0.5rem 0.5rem;
|
||||
border: 1px dashed transparent;
|
||||
border-radius: 6px;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
|
||||
&.drag-over {
|
||||
border-color: #6c63ff;
|
||||
background: #1f1f3a;
|
||||
}
|
||||
|
||||
.battlemap-slot-label {
|
||||
min-width: 160px;
|
||||
|
||||
@@ -26,6 +26,7 @@ import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-dr
|
||||
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
||||
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
||||
import { RoomsEditorComponent } from '../../../shared/rooms-editor/rooms-editor.component';
|
||||
import { FileDropDirective } from '../../../shared/file-drop.directive';
|
||||
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
||||
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||
|
||||
@@ -35,7 +36,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-scene-edit',
|
||||
imports: [ReactiveFormsModule, LucideAngularModule, ExpandableSectionComponent, LoreLinkPickerComponent, EnemyLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent, RoomsEditorComponent, TranslatePipe],
|
||||
imports: [ReactiveFormsModule, LucideAngularModule, ExpandableSectionComponent, LoreLinkPickerComponent, EnemyLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent, RoomsEditorComponent, FileDropDirective, TranslatePipe],
|
||||
templateUrl: './scene-edit.component.html',
|
||||
styleUrls: ['./scene-edit.component.scss']
|
||||
})
|
||||
@@ -282,7 +283,15 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
onBattlemapMediaSelected(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
if (file) this.uploadBattlemapMedia(file);
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
onBattlemapMediaDropped(files: File[]): void {
|
||||
if (files[0]) this.uploadBattlemapMedia(files[0]);
|
||||
}
|
||||
|
||||
private uploadBattlemapMedia(file: File): void {
|
||||
this.battlemapUploadingMedia = true;
|
||||
this.storedFileService.upload(file).subscribe({
|
||||
next: f => {
|
||||
@@ -292,13 +301,20 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
},
|
||||
error: () => { this.battlemapUploadingMedia = false; }
|
||||
});
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
onBattlemapDataSelected(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
if (file) this.uploadBattlemapData(file);
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
onBattlemapDataDropped(files: File[]): void {
|
||||
if (files[0]) this.uploadBattlemapData(files[0]);
|
||||
}
|
||||
|
||||
private uploadBattlemapData(file: File): void {
|
||||
this.battlemapUploadingData = true;
|
||||
this.storedFileService.upload(file).subscribe({
|
||||
next: f => {
|
||||
@@ -308,7 +324,6 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
},
|
||||
error: () => { this.battlemapUploadingData = false; }
|
||||
});
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
removeBattlemapMedia(): void {
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface Enemy {
|
||||
keyValueValues?: Record<string, Record<string, string>>;
|
||||
campaignId: string;
|
||||
order?: number;
|
||||
/** UUID de l'acteur de compendium Foundry d'origine (monstre importé). Null sinon. */
|
||||
foundryRef?: string | null;
|
||||
}
|
||||
|
||||
export interface EnemyCreate {
|
||||
|
||||
@@ -36,4 +36,18 @@ export class EnemyService {
|
||||
search(q: string): Observable<Enemy[]> {
|
||||
return this.http.get<Enemy[]>(`${this.apiUrl}/search`, { params: { q } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Importe un catalogue de monstres Foundry (exporté par le module) dans le
|
||||
* bestiaire de la campagne. Upsert par référence côté backend.
|
||||
*/
|
||||
importFoundryMonsters(campaignId: string, catalog: unknown): Observable<MonsterImportResult> {
|
||||
return this.http.post<MonsterImportResult>(
|
||||
`/api/campaigns/${campaignId}/import-foundry-monsters`, catalog);
|
||||
}
|
||||
}
|
||||
|
||||
export interface MonsterImportResult {
|
||||
created: number;
|
||||
updated: number;
|
||||
}
|
||||
|
||||
@@ -376,7 +376,7 @@
|
||||
<section class="card">
|
||||
<h2>{{ 'settings.data.title' | translate }}</h2>
|
||||
<p class="hint" [innerHTML]="'settings.data.hint' | translate"></p>
|
||||
<div class="actions">
|
||||
<div class="actions" appFileDrop [dropDisabled]="exporting || importing" (filesDropped)="onBackupDropped($event)">
|
||||
<button type="button" class="btn-secondary" (click)="exportData()" [disabled]="exporting || importing">
|
||||
<span>{{ (exporting ? 'settings.data.exporting' : 'settings.data.exportBtn') | translate }}</span>
|
||||
</button>
|
||||
|
||||
@@ -290,6 +290,13 @@
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 24px;
|
||||
gap: 0.5rem;
|
||||
border: 1px dashed transparent;
|
||||
border-radius: 8px;
|
||||
padding: 0.4rem;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
|
||||
&.drag-over { border-color: #667eea; background: rgba(102,126,234,0.1); }
|
||||
}
|
||||
|
||||
.alert {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { LayoutService } from '../services/layout.service';
|
||||
import { UpdatesSectionComponent } from './updates-section/updates-section.component';
|
||||
import { OllamaModelManagerComponent } from './ollama-model-manager/ollama-model-manager.component';
|
||||
import { LanguageSwitcherComponent } from '../shared/language-switcher/language-switcher.component';
|
||||
import { FileDropDirective } from '../shared/file-drop.directive';
|
||||
|
||||
/**
|
||||
* Ecran de parametrage du LLM utilise par le Brain.
|
||||
@@ -26,7 +27,7 @@ import { LanguageSwitcherComponent } from '../shared/language-switcher/language-
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-settings',
|
||||
imports: [CommonModule, FormsModule, LucideAngularModule, TranslatePipe, UpdatesSectionComponent, OllamaModelManagerComponent, LanguageSwitcherComponent],
|
||||
imports: [CommonModule, FormsModule, LucideAngularModule, TranslatePipe, UpdatesSectionComponent, OllamaModelManagerComponent, LanguageSwitcherComponent, FileDropDirective],
|
||||
templateUrl: './settings.component.html',
|
||||
styleUrls: ['./settings.component.scss']
|
||||
})
|
||||
@@ -366,8 +367,16 @@ export class SettingsComponent implements OnInit {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = ''; // permet de re-sélectionner le même fichier
|
||||
if (!file) return;
|
||||
if (file) this.importBackupFile(file);
|
||||
}
|
||||
|
||||
/** Drop d'un zip de sauvegarde sur la zone d'actions. */
|
||||
onBackupDropped(files: File[]): void {
|
||||
if (files[0]) this.importBackupFile(files[0]);
|
||||
}
|
||||
|
||||
private importBackupFile(file: File): void {
|
||||
if (this.importing) return;
|
||||
if (!confirm(this.translate.instant('settings.data.importConfirm'))) return;
|
||||
|
||||
this.importing = true;
|
||||
|
||||
55
web/src/app/shared/file-drop.directive.ts
Normal file
55
web/src/app/shared/file-drop.directive.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Directive, EventEmitter, HostBinding, HostListener, Input, Output } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Directive de drag & drop de fichiers réutilisable.
|
||||
*
|
||||
* Pose la classe `.drag-over` sur l'élément hôte pendant le survol (à styler côté
|
||||
* composant) et émet les fichiers déposés.
|
||||
*
|
||||
* Usage :
|
||||
* <div appFileDrop (filesDropped)="onDrop($event)">…</div>
|
||||
* <div appFileDrop [multiple]="true" (filesDropped)="onDrop($event)">…</div>
|
||||
*/
|
||||
@Directive({
|
||||
selector: '[appFileDrop]',
|
||||
standalone: true
|
||||
})
|
||||
export class FileDropDirective {
|
||||
/** Autoriser plusieurs fichiers (sinon seul le premier est émis). */
|
||||
@Input() multiple = false;
|
||||
|
||||
/** Désactive le drop (ex: pendant un upload en cours). */
|
||||
@Input() dropDisabled = false;
|
||||
|
||||
@Output() filesDropped = new EventEmitter<File[]>();
|
||||
|
||||
@HostBinding('class.drag-over') isOver = false;
|
||||
|
||||
@HostListener('dragover', ['$event'])
|
||||
onDragOver(event: DragEvent): void {
|
||||
if (this.dropDisabled) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
|
||||
this.isOver = true;
|
||||
}
|
||||
|
||||
@HostListener('dragleave', ['$event'])
|
||||
onDragLeave(event: DragEvent): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.isOver = false;
|
||||
}
|
||||
|
||||
@HostListener('drop', ['$event'])
|
||||
onDrop(event: DragEvent): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.isOver = false;
|
||||
if (this.dropDisabled) return;
|
||||
const list = event.dataTransfer?.files;
|
||||
if (!list || list.length === 0) return;
|
||||
const files = Array.from(list);
|
||||
this.filesDropped.emit(this.multiple ? files : [files[0]]);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
<!-- Mode compact : bouton "+ ajouter" carre, utilise dans les galeries. -->
|
||||
@if (compact) {
|
||||
<label class="upload-compact" [class.loading]="uploading" [title]="errorMessage || ('imageUploader.addImage' | translate)">
|
||||
<label class="upload-compact"
|
||||
[class.loading]="uploading"
|
||||
[class.drag-over]="dragOver"
|
||||
(dragover)="onDragOver($event)"
|
||||
(dragleave)="onDragLeave()"
|
||||
(drop)="onDrop($event)"
|
||||
[title]="errorMessage || ('imageUploader.addImage' | translate)">
|
||||
@if (!uploading) {
|
||||
<lucide-icon [img]="Upload" [size]="18"></lucide-icon>
|
||||
<span>{{ 'common.add' | translate }}</span>
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||
|
||||
&:hover { border-color: #6c63ff; color: #a5b4fc; background: #1f1f3a; }
|
||||
&.drag-over { border-color: #6c63ff; background: #1f1f3a; color: white; }
|
||||
&.loading { cursor: progress; opacity: 0.7; }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user