Initial commit - LoreMind project
This commit is contained in:
18
web/src/app/app.component.html
Normal file
18
web/src/app/app.component.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<div class="app-container">
|
||||
<app-sidebar></app-sidebar>
|
||||
|
||||
<ng-container *ngIf="sidebarConfig$ | async as config">
|
||||
<app-secondary-sidebar
|
||||
[title]="config.title"
|
||||
[items]="config.items"
|
||||
[createActions]="config.createActions"
|
||||
[bottomPanel]="config.bottomPanel || null">
|
||||
</app-secondary-sidebar>
|
||||
</ng-container>
|
||||
|
||||
<main class="main-content">
|
||||
<router-outlet></router-outlet>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<app-global-search></app-global-search>
|
||||
10
web/src/app/app.component.scss
Normal file
10
web/src/app/app.component.scss
Normal file
@@ -0,0 +1,10 @@
|
||||
.app-container {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding: 2rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
33
web/src/app/app.component.ts
Normal file
33
web/src/app/app.component.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Component, HostListener } from '@angular/core';
|
||||
import { AsyncPipe, NgIf } from '@angular/common';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { SidebarComponent } from './sidebar/sidebar.component';
|
||||
import { SecondarySidebarComponent } from './shared/secondary-sidebar/secondary-sidebar.component';
|
||||
import { GlobalSearchComponent } from './shared/global-search/global-search.component';
|
||||
import { LayoutService } from './services/layout.service';
|
||||
import { GlobalSearchService } from './services/global-search.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
standalone: true,
|
||||
imports: [RouterOutlet, SidebarComponent, SecondarySidebarComponent, GlobalSearchComponent, AsyncPipe, NgIf],
|
||||
templateUrl: './app.component.html',
|
||||
styleUrls: ['./app.component.scss']
|
||||
})
|
||||
export class AppComponent {
|
||||
readonly sidebarConfig$ = this.layoutService.secondarySidebar$;
|
||||
|
||||
constructor(
|
||||
private layoutService: LayoutService,
|
||||
private globalSearch: GlobalSearchService
|
||||
) {}
|
||||
|
||||
@HostListener('document:keydown', ['$event'])
|
||||
onKeydown(event: KeyboardEvent): void {
|
||||
// Ctrl+K (Windows/Linux) ou Cmd+K (macOS)
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
|
||||
event.preventDefault();
|
||||
this.globalSearch.toggle();
|
||||
}
|
||||
}
|
||||
}
|
||||
23
web/src/app/app.routes.ts
Normal file
23
web/src/app/app.routes.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: 'lore', loadComponent: () => import('./lore/lore.component').then(m => m.LoreComponent) },
|
||||
{ path: 'lore/:id', loadComponent: () => import('./lore/lore-detail/lore-detail.component').then(m => m.LoreDetailComponent) },
|
||||
{ path: 'lore/:loreId/nodes/create', loadComponent: () => import('./lore/lore-node-create/lore-node-create.component').then(m => m.LoreNodeCreateComponent) },
|
||||
{ path: 'lore/:loreId/folders/:parentId/create', loadComponent: () => import('./lore/lore-node-create/lore-node-create.component').then(m => m.LoreNodeCreateComponent) },
|
||||
{ path: 'lore/:loreId/folders/:folderId/edit', loadComponent: () => import('./lore/lore-node-edit/lore-node-edit.component').then(m => m.LoreNodeEditComponent) },
|
||||
{ path: 'lore/:loreId/templates/create', loadComponent: () => import('./lore/template-create/template-create.component').then(m => m.TemplateCreateComponent) },
|
||||
{ path: 'lore/:loreId/templates/:templateId', loadComponent: () => import('./lore/template-edit/template-edit.component').then(m => m.TemplateEditComponent) },
|
||||
{ path: 'lore/:loreId/pages/create', loadComponent: () => import('./lore/page-create/page-create.component').then(m => m.PageCreateComponent) },
|
||||
{ path: 'lore/:loreId/nodes/:nodeId/pages/create', loadComponent: () => import('./lore/page-create/page-create.component').then(m => m.PageCreateComponent) },
|
||||
{ path: 'lore/:loreId/pages/:pageId', loadComponent: () => import('./lore/page-edit/page-edit.component').then(m => m.PageEditComponent) },
|
||||
{ path: 'campaigns', loadComponent: () => import('./campaigns/campaigns.component').then(m => m.CampaignsComponent) },
|
||||
{ path: 'campaigns/:id', loadComponent: () => import('./campaigns/campaign-detail/campaign-detail.component').then(m => m.CampaignDetailComponent) },
|
||||
{ path: 'campaigns/:campaignId/arcs/create', loadComponent: () => import('./campaigns/arc-create/arc-create.component').then(m => m.ArcCreateComponent) },
|
||||
{ path: 'campaigns/:campaignId/arcs/:arcId', loadComponent: () => import('./campaigns/arc-edit/arc-edit.component').then(m => m.ArcEditComponent) },
|
||||
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/create', loadComponent: () => import('./campaigns/chapter-create/chapter-create.component').then(m => m.ChapterCreateComponent) },
|
||||
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId', loadComponent: () => import('./campaigns/chapter-edit/chapter-edit.component').then(m => m.ChapterEditComponent) },
|
||||
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/create', loadComponent: () => import('./campaigns/scene-create/scene-create.component').then(m => m.SceneCreateComponent) },
|
||||
{ path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/:sceneId', loadComponent: () => import('./campaigns/scene-edit/scene-edit.component').then(m => m.SceneEditComponent) },
|
||||
{ path: '', redirectTo: '/lore', pathMatch: 'full' }
|
||||
];
|
||||
37
web/src/app/campaigns/arc-create/arc-create.component.html
Normal file
37
web/src/app/campaigns/arc-create/arc-create.component.html
Normal file
@@ -0,0 +1,37 @@
|
||||
<div class="arc-create-page">
|
||||
|
||||
<div class="page-header">
|
||||
<h1>Créer un nouvel arc narratif</h1>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="submit()" class="arc-form">
|
||||
|
||||
<div class="field">
|
||||
<label>Nom de l'arc *</label>
|
||||
<input
|
||||
type="text"
|
||||
formControlName="name"
|
||||
placeholder="Ex: L'Ombre du Nord"
|
||||
[class.invalid]="form.get('name')?.invalid && form.get('name')?.touched"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Description</label>
|
||||
<textarea
|
||||
formControlName="description"
|
||||
placeholder="Décrivez l'arc narratif principal..."
|
||||
rows="5">
|
||||
</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn-primary" [disabled]="form.invalid">
|
||||
Créer l'arc
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
18
web/src/app/campaigns/arc-create/arc-create.component.scss
Normal file
18
web/src/app/campaigns/arc-create/arc-create.component.scss
Normal file
@@ -0,0 +1,18 @@
|
||||
.arc-create-page {
|
||||
padding: 2.5rem 2rem;
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
// Override local : titre en violet (pas en blanc comme le .page-header global).
|
||||
.page-header h1 { color: #a5b4fc; }
|
||||
|
||||
.arc-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
// Spécificité locale : le bouton "Créer l'arc" prend toute la largeur dispo.
|
||||
// Tous les autres styles (.field, .btn-primary de base, .btn-secondary, .form-actions)
|
||||
// sont fournis globalement par @app/styles/_forms.scss et _buttons.scss.
|
||||
.btn-primary { flex: 1; }
|
||||
95
web/src/app/campaigns/arc-create/arc-create.component.ts
Normal file
95
web/src/app/campaigns/arc-create/arc-create.component.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { forkJoin } from 'rxjs';
|
||||
import { LucideAngularModule, BookOpen } from 'lucide-angular';
|
||||
import { CampaignService } from '../../services/campaign.service';
|
||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||
import { Campaign } from '../../services/campaign.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
||||
|
||||
/**
|
||||
* Écran de création d'un nouvel Arc narratif (contexte Campagne).
|
||||
* Formulaire simple : nom + description. L'ordre est auto-calculé depuis
|
||||
* le nombre d'arcs existants dans la campagne courante.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-arc-create',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule],
|
||||
templateUrl: './arc-create.component.html',
|
||||
styleUrls: ['./arc-create.component.scss']
|
||||
})
|
||||
export class ArcCreateComponent implements OnInit, OnDestroy {
|
||||
readonly BookOpen = BookOpen;
|
||||
|
||||
form: FormGroup;
|
||||
campaignId = '';
|
||||
private existingArcCount = 0;
|
||||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private layoutService: LayoutService
|
||||
) {
|
||||
this.form = this.fb.group({
|
||||
name: ['', Validators.required],
|
||||
description: ['']
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.campaignId = this.route.snapshot.paramMap.get('campaignId')!;
|
||||
this.loadLayout();
|
||||
}
|
||||
|
||||
private loadLayout(): void {
|
||||
forkJoin({
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
|
||||
}).subscribe(({ campaign, allCampaigns, treeData }) => {
|
||||
this.existingArcCount = treeData.arcs.length;
|
||||
|
||||
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
|
||||
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
|
||||
}));
|
||||
|
||||
this.layoutService.show({
|
||||
title: campaign.name,
|
||||
items: buildCampaignTree(this.campaignId, treeData),
|
||||
footerLabel: 'Toutes les campagnes',
|
||||
createActions: [
|
||||
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${this.campaignId}/arcs/create` }
|
||||
],
|
||||
globalItems,
|
||||
globalBackLabel: 'Toutes les campagnes',
|
||||
globalBackRoute: '/campaigns'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (this.form.invalid) return;
|
||||
this.campaignService.createArc({
|
||||
name: this.form.value.name,
|
||||
description: this.form.value.description,
|
||||
campaignId: this.campaignId,
|
||||
order: this.existingArcCount + 1
|
||||
}).subscribe({
|
||||
next: () => this.router.navigate(['/campaigns', this.campaignId]),
|
||||
error: () => console.error('Erreur lors de la création de l\'arc')
|
||||
});
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.router.navigate(['/campaigns', this.campaignId]);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.layoutService.hide();
|
||||
}
|
||||
}
|
||||
110
web/src/app/campaigns/arc-edit/arc-edit.component.html
Normal file
110
web/src/app/campaigns/arc-edit/arc-edit.component.html
Normal file
@@ -0,0 +1,110 @@
|
||||
<div class="edit-page">
|
||||
|
||||
<div class="page-header">
|
||||
<h1>{{ arc?.name || 'Arc' }}</h1>
|
||||
<p class="subtitle">Arc narratif</p>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="submit()" class="edit-form">
|
||||
|
||||
<div class="field">
|
||||
<label>Titre de l'arc *</label>
|
||||
<input
|
||||
type="text"
|
||||
formControlName="name"
|
||||
placeholder="Ex: L'Ombre du Nord"
|
||||
[class.invalid]="form.get('name')?.invalid && form.get('name')?.touched"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Synopsis de l'arc</label>
|
||||
<textarea
|
||||
formControlName="description"
|
||||
placeholder="Décrivez l'histoire principale de cet arc narratif..."
|
||||
rows="5">
|
||||
</textarea>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label>Thèmes principaux</label>
|
||||
<textarea
|
||||
formControlName="themes"
|
||||
placeholder="Quels sont les thèmes explorés dans cet arc ? (trahison, rédemption...)"
|
||||
rows="4">
|
||||
</textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Enjeux globaux</label>
|
||||
<textarea
|
||||
formControlName="stakes"
|
||||
placeholder="Quels sont les enjeux majeurs de cet arc pour les personnages ?"
|
||||
rows="4">
|
||||
</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Notes et planification du MJ</label>
|
||||
<textarea
|
||||
formControlName="gmNotes"
|
||||
placeholder="Vos notes sur la direction de l'arc, les twists prévus, les révélations importantes..."
|
||||
rows="5">
|
||||
</textarea>
|
||||
<small class="field-hint">Ces notes sont privées et ne seront pas exportées vers FoundryVTT.</small>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Récompenses et progression</label>
|
||||
<textarea
|
||||
formControlName="rewards"
|
||||
placeholder="Quelles récompenses les joueurs obtiendront-ils ? Objets, niveaux, connaissances, contacts..."
|
||||
rows="4">
|
||||
</textarea>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Dénouement prévu</label>
|
||||
<textarea
|
||||
formControlName="resolution"
|
||||
placeholder="Comment cet arc devrait-il se terminer ? Quelles sont les issues possibles ?"
|
||||
rows="4">
|
||||
</textarea>
|
||||
</div>
|
||||
|
||||
<!-- ===== Pages Lore associées (phase B2 cross-context) ===== -->
|
||||
<div class="field" *ngIf="loreId">
|
||||
<label>Pages Lore associées</label>
|
||||
<app-lore-link-picker
|
||||
[value]="relatedPageIds"
|
||||
[availablePages]="availablePages"
|
||||
[loreId]="loreId"
|
||||
(valueChange)="relatedPageIds = $event">
|
||||
</app-lore-link-picker>
|
||||
<small class="field-hint">
|
||||
Liez cet arc à des PNJ, lieux ou éléments du Lore. Cliquez sur un chip pour ouvrir la page associée.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="field lore-hint" *ngIf="!loreId">
|
||||
<small class="field-hint">
|
||||
💡 Cette campagne n'est associée à aucun univers. Associez-la à un Lore dans l'écran de la campagne
|
||||
pour pouvoir lier cet arc à des pages du Lore (PNJ, lieux, etc.).
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn-primary" [disabled]="form.invalid">
|
||||
Sauvegarder
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
|
||||
<button type="button" class="btn-danger" (click)="delete()">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
21
web/src/app/campaigns/arc-edit/arc-edit.component.scss
Normal file
21
web/src/app/campaigns/arc-edit/arc-edit.component.scss
Normal file
@@ -0,0 +1,21 @@
|
||||
.edit-page {
|
||||
padding: 2.5rem 2rem;
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
// Formulaire vertical classique.
|
||||
.edit-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
// Override local : dans ce header, le bouton Supprimer est poussé à droite
|
||||
// via margin-left auto (les boutons Annuler/Sauvegarder restent groupés à gauche).
|
||||
// Styles de base fournis globalement par @app/styles/_buttons.scss (.btn-danger).
|
||||
.btn-danger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
168
web/src/app/campaigns/arc-edit/arc-edit.component.ts
Normal file
168
web/src/app/campaigns/arc-edit/arc-edit.component.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { switchMap } from 'rxjs/operators';
|
||||
import { LucideAngularModule, Trash2 } from 'lucide-angular';
|
||||
import { CampaignService } from '../../services/campaign.service';
|
||||
import { PageService } from '../../services/page.service';
|
||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||
import { PageTitleService } from '../../services/page-title.service';
|
||||
import { Campaign, Arc } from '../../services/campaign.model';
|
||||
import { Page } from '../../services/page.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
||||
import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link-picker.component';
|
||||
|
||||
/**
|
||||
* Écran de détail/modification d'un Arc.
|
||||
* Route : /campaigns/:campaignId/arcs/:arcId
|
||||
*
|
||||
* Intègre le picker de pages Lore (phase B2 cross-context) :
|
||||
* si la campagne parente est associée à un Lore (`campaign.loreId`), les pages
|
||||
* de ce Lore sont proposées dans un autocomplete pour lier cet arc à des
|
||||
* personnages / lieux / objets du Lore.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-arc-edit',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, LoreLinkPickerComponent],
|
||||
templateUrl: './arc-edit.component.html',
|
||||
styleUrls: ['./arc-edit.component.scss']
|
||||
})
|
||||
export class ArcEditComponent implements OnInit, OnDestroy {
|
||||
readonly Trash2 = Trash2;
|
||||
|
||||
form: FormGroup;
|
||||
campaignId = '';
|
||||
arcId = '';
|
||||
arc: Arc | null = null;
|
||||
|
||||
/** Pages disponibles pour le picker (vide si la campagne n'a pas de loreId). */
|
||||
availablePages: Page[] = [];
|
||||
/** ID du Lore associé à la campagne (null si campagne sans univers). */
|
||||
loreId: string | null = null;
|
||||
/** IDs des pages liées à cet arc (bind sur app-lore-link-picker). */
|
||||
relatedPageIds: string[] = [];
|
||||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService
|
||||
) {
|
||||
this.form = this.fb.group({
|
||||
name: ['', Validators.required],
|
||||
description: [''],
|
||||
themes: [''],
|
||||
stakes: [''],
|
||||
gmNotes: [''],
|
||||
rewards: [''],
|
||||
resolution: ['']
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
// On s'abonne à paramMap plutôt que de lire snapshot une fois : Angular
|
||||
// réutilise le composant quand on navigue entre arcs frères via l'arbre
|
||||
// (même route pattern), et ngOnInit ne se relance pas.
|
||||
this.route.paramMap.subscribe(pm => {
|
||||
const newCampaignId = pm.get('campaignId')!;
|
||||
const newArcId = pm.get('arcId')!;
|
||||
if (newArcId !== this.arcId || newCampaignId !== this.campaignId) {
|
||||
this.campaignId = newCampaignId;
|
||||
this.arcId = newArcId;
|
||||
this.loadAll();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private loadAll(): void {
|
||||
// On déclenche d'abord les 4 appels indépendants, puis on charge les pages
|
||||
// du Lore associé UNIQUEMENT si la campagne en a un (switchMap conditionnel).
|
||||
forkJoin({
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
arc: this.campaignService.getArcById(this.arcId),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
|
||||
}).pipe(
|
||||
switchMap(data => {
|
||||
const lid = data.campaign.loreId ?? null;
|
||||
// Pas de loreId → pas de picker, on retourne une liste vide.
|
||||
const pages$ = lid ? this.pageService.getByLoreId(lid) : of([] as Page[]);
|
||||
return pages$.pipe(
|
||||
switchMap(pages => of({ ...data, pages, loreId: lid }))
|
||||
);
|
||||
})
|
||||
).subscribe(({ campaign, allCampaigns, arc, treeData, pages, loreId }) => {
|
||||
this.arc = arc;
|
||||
this.loreId = loreId;
|
||||
this.availablePages = pages;
|
||||
this.relatedPageIds = [...(arc.relatedPageIds ?? [])];
|
||||
this.pageTitleService.set(arc.name);
|
||||
this.form.patchValue({
|
||||
name: arc.name,
|
||||
description: arc.description ?? '',
|
||||
themes: arc.themes ?? '',
|
||||
stakes: arc.stakes ?? '',
|
||||
gmNotes: arc.gmNotes ?? '',
|
||||
rewards: arc.rewards ?? '',
|
||||
resolution: arc.resolution ?? ''
|
||||
});
|
||||
|
||||
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
|
||||
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
|
||||
}));
|
||||
|
||||
this.layoutService.show({
|
||||
title: campaign.name,
|
||||
items: buildCampaignTree(this.campaignId, treeData),
|
||||
footerLabel: 'Toutes les campagnes',
|
||||
createActions: [
|
||||
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${this.campaignId}/arcs/create` }
|
||||
],
|
||||
globalItems,
|
||||
globalBackLabel: 'Toutes les campagnes',
|
||||
globalBackRoute: '/campaigns'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (this.form.invalid || !this.arc) return;
|
||||
this.campaignService.updateArc(this.arcId, {
|
||||
name: this.form.value.name,
|
||||
description: this.form.value.description,
|
||||
campaignId: this.campaignId,
|
||||
order: this.arc.order ?? 1,
|
||||
themes: this.form.value.themes,
|
||||
stakes: this.form.value.stakes,
|
||||
gmNotes: this.form.value.gmNotes,
|
||||
rewards: this.form.value.rewards,
|
||||
resolution: this.form.value.resolution,
|
||||
relatedPageIds: this.relatedPageIds
|
||||
}).subscribe({
|
||||
next: () => this.router.navigate(['/campaigns', this.campaignId]),
|
||||
error: () => console.error('Erreur lors de la sauvegarde')
|
||||
});
|
||||
}
|
||||
|
||||
delete(): void {
|
||||
if (!confirm(`Supprimer l'arc "${this.arc?.name}" ? Cette action est irréversible.`)) return;
|
||||
this.campaignService.deleteArc(this.arcId).subscribe({
|
||||
next: () => this.router.navigate(['/campaigns', this.campaignId]),
|
||||
error: () => console.error('Erreur lors de la suppression')
|
||||
});
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.router.navigate(['/campaigns', this.campaignId]);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.layoutService.hide();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<div class="modal-backdrop" (click)="onCancel()">
|
||||
<div class="modal" (click)="$event.stopPropagation()">
|
||||
|
||||
<div class="modal-header">
|
||||
<h2>Créer une nouvelle Campagne</h2>
|
||||
<button class="btn-close" (click)="onCancel()">
|
||||
<lucide-icon [img]="X" [size]="18"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="submit()">
|
||||
|
||||
<div class="field">
|
||||
<label>Nom de la campagne *</label>
|
||||
<input
|
||||
type="text"
|
||||
formControlName="name"
|
||||
placeholder="Ex: L'Ombre du Nord, Les Héritiers Oubliés..."
|
||||
[class.invalid]="form.get('name')?.invalid && form.get('name')?.touched"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Description / Pitch</label>
|
||||
<textarea
|
||||
formControlName="description"
|
||||
placeholder="Résumez l'intrigue principale de votre campagne..."
|
||||
rows="5"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Nombre de joueurs</label>
|
||||
<input type="number" formControlName="playerCount" min="1" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Univers associé</label>
|
||||
<select formControlName="loreId">
|
||||
<option value="">— Aucun univers (campagne libre) —</option>
|
||||
<option *ngFor="let lore of availableLores" [value]="lore.id">{{ lore.name }}</option>
|
||||
</select>
|
||||
<p class="hint">
|
||||
Optionnel. Si associée, vous pourrez lier arcs, chapitres et scènes aux pages du Lore.
|
||||
Laissez vide pour un one-shot ou si vous créerez le Lore plus tard.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<p><strong>💡 Organisation :</strong> Votre campagne sera structurée en :</p>
|
||||
<ul>
|
||||
<li><strong>Arcs</strong> - Les grandes phases narratives</li>
|
||||
<li><strong>Chapitres</strong> - Les segments d'un arc</li>
|
||||
<li><strong>Scènes</strong> - Les moments de jeu individuels</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="submit" class="btn-primary" [disabled]="form.invalid">
|
||||
<lucide-icon [img]="BookCopy" [size]="16"></lucide-icon>
|
||||
Créer la campagne
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="onCancel()">Annuler</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,123 @@
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: #111827;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
|
||||
h2 { color: white; font-size: 1.25rem; font-weight: 600; }
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover { color: white; }
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
color: #d1d5db;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
input, textarea {
|
||||
width: 100%;
|
||||
background: #1f2937;
|
||||
border: 1px solid #374151;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1rem;
|
||||
color: white;
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
resize: none;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&::placeholder { color: #4b5563; }
|
||||
&:focus { border-color: #6c63ff; }
|
||||
&.invalid { border-color: #ef4444; }
|
||||
}
|
||||
|
||||
input[type="number"] { width: 120px; }
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: #1f2937;
|
||||
border-radius: 8px;
|
||||
padding: 1rem 1.25rem;
|
||||
margin-bottom: 2rem;
|
||||
font-size: 0.875rem;
|
||||
color: #9ca3af;
|
||||
line-height: 1.8;
|
||||
|
||||
ul {
|
||||
margin: 0.5rem 0 0 1.25rem;
|
||||
li strong { color: #d1d5db; }
|
||||
}
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #6c63ff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover:not(:disabled) { background: #5b52e0; }
|
||||
&:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #1f2937;
|
||||
color: #d1d5db;
|
||||
border: 1px solid #374151;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover { background: #374151; }
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Component, EventEmitter, OnInit, Output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { LucideAngularModule, BookCopy, X } from 'lucide-angular';
|
||||
import { LoreService } from '../../services/lore.service';
|
||||
import { Lore } from '../../services/lore.model';
|
||||
|
||||
/**
|
||||
* Payload émis vers le parent à la création d'une campagne.
|
||||
* `loreId` est optionnel (null = campagne sans univers associé).
|
||||
*/
|
||||
export interface CampaignCreatePayload {
|
||||
name: string;
|
||||
description: string;
|
||||
playerCount: number;
|
||||
loreId: string | null;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-campaign-create',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule],
|
||||
templateUrl: './campaign-create.component.html',
|
||||
styleUrls: ['./campaign-create.component.scss']
|
||||
})
|
||||
export class CampaignCreateComponent implements OnInit {
|
||||
@Output() close = new EventEmitter<void>();
|
||||
@Output() created = new EventEmitter<CampaignCreatePayload>();
|
||||
|
||||
readonly BookCopy = BookCopy;
|
||||
readonly X = X;
|
||||
|
||||
form: FormGroup;
|
||||
/** Lores disponibles pour association. Chargés à l'ouverture de la modal. */
|
||||
availableLores: Lore[] = [];
|
||||
|
||||
constructor(private fb: FormBuilder, private loreService: LoreService) {
|
||||
this.form = this.fb.group({
|
||||
name: ['', Validators.required],
|
||||
description: [''],
|
||||
playerCount: [4, [Validators.required, Validators.min(1)]],
|
||||
// Valeur par défaut : chaîne vide = "— Aucun lore associé —".
|
||||
// Le service normalise ensuite ""/null en null côté backend.
|
||||
loreId: ['']
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loreService.getAllLores().subscribe({
|
||||
next: (lores) => this.availableLores = lores,
|
||||
error: () => this.availableLores = []
|
||||
});
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (this.form.invalid) return;
|
||||
const raw = this.form.value;
|
||||
this.created.emit({
|
||||
name: raw.name,
|
||||
description: raw.description,
|
||||
playerCount: raw.playerCount,
|
||||
loreId: raw.loreId ? raw.loreId : null
|
||||
});
|
||||
}
|
||||
|
||||
onCancel(): void {
|
||||
this.close.emit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<div class="campaign-detail" *ngIf="campaign">
|
||||
|
||||
<!-- ============ Header : mode lecture ============ -->
|
||||
<div class="detail-header" *ngIf="!editing">
|
||||
<div class="header-texts">
|
||||
<h1>{{ campaign.name }}</h1>
|
||||
<p class="description">{{ campaign.description }}</p>
|
||||
<div class="meta">
|
||||
<span class="badge">{{ campaign.playerCount || 0 }} joueurs</span>
|
||||
|
||||
<!-- Badge "Univers" : lien vers le Lore associé si présent -->
|
||||
<a *ngIf="linkedLore"
|
||||
class="badge badge-lore"
|
||||
[routerLink]="['/lore', linkedLore.id]"
|
||||
title="Ouvrir l'univers associé">
|
||||
<lucide-icon [img]="Globe" [size]="12"></lucide-icon>
|
||||
{{ linkedLore.name }}
|
||||
</a>
|
||||
|
||||
<!-- Campagne liée à un Lore qui n'existe plus (supprimé ailleurs) -->
|
||||
<span *ngIf="campaign.loreId && !linkedLore" class="badge badge-lore-missing" title="L'univers associé est introuvable">
|
||||
<lucide-icon [img]="Globe" [size]="12"></lucide-icon>
|
||||
Univers introuvable
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="btn-secondary" (click)="startEdit()">
|
||||
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
|
||||
Modifier
|
||||
</button>
|
||||
<button type="button" class="btn-danger" (click)="deleteCampaign()">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ Header : mode édition inline ============ -->
|
||||
<div class="detail-header edit-mode" *ngIf="editing">
|
||||
<div class="field">
|
||||
<label>Nom</label>
|
||||
<input type="text" [(ngModel)]="editName" name="editName" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Description</label>
|
||||
<textarea [(ngModel)]="editDescription" name="editDescription" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Univers associé</label>
|
||||
<select [(ngModel)]="editLoreId" name="editLoreId">
|
||||
<option value="">— Aucun univers (campagne libre) —</option>
|
||||
<option *ngFor="let lore of availableLores" [value]="lore.id">{{ lore.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="btn-primary" (click)="saveEdit()" [disabled]="!editName.trim()">
|
||||
Sauvegarder
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="cancelEdit()">
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="arcs-section">
|
||||
<div class="section-header">
|
||||
<h2>Arcs narratifs</h2>
|
||||
<button class="btn-add">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
Nouvel arc
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="arcs-grid" *ngIf="arcs.length > 0">
|
||||
<div class="arc-card" *ngFor="let arc of arcs">
|
||||
<lucide-icon [img]="Swords" [size]="24" class="arc-icon"></lucide-icon>
|
||||
<span class="arc-name">{{ arc.name }}</span>
|
||||
<span class="arc-meta">{{ arc.chapterCount || 0 }} chapitres</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="empty-state" *ngIf="arcs.length === 0">
|
||||
<lucide-icon [img]="Swords" [size]="40" class="empty-icon"></lucide-icon>
|
||||
<p>Aucun arc narratif pour le moment.</p>
|
||||
<button class="btn-add-first">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
Créer votre premier arc
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,203 @@
|
||||
.campaign-detail {
|
||||
padding: 2.5rem 2rem;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
margin-bottom: 2.5rem;
|
||||
|
||||
h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: #6b7280;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
background: #1e3a5f;
|
||||
color: #60a5fa;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
// Lien cliquable vers le Lore associé (weak cross-context link).
|
||||
.badge-lore {
|
||||
background: #2d2450;
|
||||
color: #a78bfa;
|
||||
transition: background 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: #3d3168;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
// Cas dégradé : loreId renseigné mais Lore introuvable (supprimé).
|
||||
.badge-lore-missing {
|
||||
background: #3a1e1e;
|
||||
color: #f87171;
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
|
||||
.header-texts { flex: 1; min-width: 0; }
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// Variante mode édition : champs empilés verticalement.
|
||||
&.edit-mode {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
label { color: #9ca3af; font-size: 0.8rem; font-weight: 500; }
|
||||
input, textarea, select {
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
padding: 0.6rem 0.85rem;
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
|
||||
&:focus { outline: none; border-color: #6c63ff; }
|
||||
}
|
||||
textarea { resize: vertical; }
|
||||
}
|
||||
|
||||
.header-actions { justify-content: flex-end; }
|
||||
}
|
||||
}
|
||||
|
||||
// Boutons partagés.
|
||||
.btn-primary, .btn-secondary, .btn-danger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
.btn-primary { background: #6c63ff; color: white; &:hover:not(:disabled) { background: #5b52e0; } }
|
||||
.btn-secondary { background: #1f2937; color: #d1d5db; &:hover:not(:disabled) { background: #374151; } }
|
||||
.btn-danger { background: #3a1e1e; color: #f87171; &:hover:not(:disabled) { background: #5a2e2e; } }
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
h2 { color: #d1d5db; font-size: 1rem; font-weight: 600; }
|
||||
}
|
||||
|
||||
.btn-add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: #6c63ff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover { background: #5b52e0; }
|
||||
}
|
||||
|
||||
.arcs-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.arc-card {
|
||||
background: #111827;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 10px;
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, transform 0.2s;
|
||||
|
||||
&:hover { border-color: #6c63ff; transform: translateY(-2px); }
|
||||
|
||||
.arc-icon { color: #6c63ff; }
|
||||
.arc-name { color: white; font-size: 0.9rem; font-weight: 600; }
|
||||
.arc-meta { color: #6b7280; font-size: 0.75rem; }
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 4rem 2rem;
|
||||
color: #6b7280;
|
||||
|
||||
.empty-icon { color: #374151; }
|
||||
p { font-size: 0.95rem; }
|
||||
}
|
||||
|
||||
.btn-add-first {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.6rem 1.25rem;
|
||||
background: #6c63ff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover { background: #5b52e0; }
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2 } from 'lucide-angular';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { catchError, switchMap, filter, map } from 'rxjs/operators';
|
||||
import { CampaignService } from '../../services/campaign.service';
|
||||
import { LoreService } from '../../services/lore.service';
|
||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||
import { PageTitleService } from '../../services/page-title.service';
|
||||
import { Campaign, Arc } from '../../services/campaign.model';
|
||||
import { Lore } from '../../services/lore.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree, CampaignTreeData } from '../campaign-tree.helper';
|
||||
|
||||
@Component({
|
||||
selector: 'app-campaign-detail',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, LucideAngularModule, RouterLink],
|
||||
templateUrl: './campaign-detail.component.html',
|
||||
styleUrls: ['./campaign-detail.component.scss']
|
||||
})
|
||||
export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
readonly Swords = Swords;
|
||||
readonly Plus = Plus;
|
||||
readonly Globe = Globe;
|
||||
readonly Pencil = Pencil;
|
||||
readonly Trash2 = Trash2;
|
||||
|
||||
campaign: Campaign | null = null;
|
||||
arcs: Arc[] = [];
|
||||
/** Lore associé si `campaign.loreId` est renseigné ; sinon null. */
|
||||
linkedLore: Lore | null = null;
|
||||
/** Lores disponibles pour changer l'association en mode édition. */
|
||||
availableLores: Lore[] = [];
|
||||
|
||||
/** Mode édition inline. */
|
||||
editing = false;
|
||||
editName = '';
|
||||
editDescription = '';
|
||||
editLoreId = '';
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private loreService: LoreService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
// switchMap annule automatiquement le load précédent si l'utilisateur
|
||||
// change de campagne avant que le forkJoin ne réponde — évite qu'une
|
||||
// réponse en retard écrase des données plus récentes (race condition).
|
||||
this.route.paramMap.pipe(
|
||||
map(pm => pm.get('id')),
|
||||
filter((id): id is string => !!id && id !== this.campaign?.id),
|
||||
switchMap(id => forkJoin({
|
||||
campaign: this.campaignService.getCampaignById(id),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
treeData: loadCampaignTreeData(this.campaignService, id).pipe(
|
||||
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {} } as CampaignTreeData))
|
||||
)
|
||||
}))
|
||||
).subscribe(({ campaign, allCampaigns, treeData }) => {
|
||||
this.campaign = campaign;
|
||||
this.editing = false;
|
||||
this.loadLinkedLore(campaign);
|
||||
this.arcs = treeData.arcs;
|
||||
this.showLayout(allCampaigns, treeData);
|
||||
this.pageTitleService.set(campaign.name);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Recharge explicitement après une mise à jour locale (ex: saveEdit).
|
||||
* Contrairement au flux ngOnInit, on bypass le filter sur l'ID puisqu'on
|
||||
* veut rafraîchir même si l'ID n'a pas changé.
|
||||
*/
|
||||
private reload(id: string): void {
|
||||
forkJoin({
|
||||
campaign: this.campaignService.getCampaignById(id),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
treeData: loadCampaignTreeData(this.campaignService, id).pipe(
|
||||
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {} } as CampaignTreeData))
|
||||
)
|
||||
}).subscribe(({ campaign, allCampaigns, treeData }) => {
|
||||
this.campaign = campaign;
|
||||
this.editing = false;
|
||||
this.loadLinkedLore(campaign);
|
||||
this.arcs = treeData.arcs;
|
||||
this.showLayout(allCampaigns, treeData);
|
||||
this.pageTitleService.set(campaign.name);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge le Lore associé (si loreId présent). On swallow l'erreur :
|
||||
* si le Lore a été supprimé entre-temps, on affiche simplement "Univers introuvable".
|
||||
*/
|
||||
private loadLinkedLore(campaign: Campaign): void {
|
||||
if (!campaign.loreId) {
|
||||
this.linkedLore = null;
|
||||
return;
|
||||
}
|
||||
this.loreService.getLoreById(campaign.loreId).pipe(
|
||||
catchError(() => of(null))
|
||||
).subscribe(lore => this.linkedLore = lore);
|
||||
}
|
||||
|
||||
private showLayout(allCampaigns: Campaign[], data: CampaignTreeData): void {
|
||||
const campaignId = this.campaign!.id!;
|
||||
const globalItems: GlobalItem[] = allCampaigns.map(c => ({
|
||||
id: c.id!,
|
||||
name: c.name,
|
||||
route: `/campaigns/${c.id}`
|
||||
}));
|
||||
|
||||
this.layoutService.show({
|
||||
title: this.campaign!.name,
|
||||
items: buildCampaignTree(campaignId, data),
|
||||
footerLabel: 'Toutes les campagnes',
|
||||
createActions: [
|
||||
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${campaignId}/arcs/create` }
|
||||
],
|
||||
globalItems,
|
||||
globalBackLabel: 'Toutes les campagnes',
|
||||
globalBackRoute: '/campaigns'
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────── Édition / suppression de la Campagne ───────────────
|
||||
|
||||
startEdit(): void {
|
||||
if (!this.campaign) return;
|
||||
this.editName = this.campaign.name;
|
||||
this.editDescription = this.campaign.description ?? '';
|
||||
this.editLoreId = this.campaign.loreId ?? '';
|
||||
// On charge les Lores disponibles pour le select uniquement à l'entrée en mode édition.
|
||||
this.loreService.getAllLores().subscribe({
|
||||
next: (lores) => this.availableLores = lores,
|
||||
error: () => this.availableLores = []
|
||||
});
|
||||
this.editing = true;
|
||||
}
|
||||
|
||||
cancelEdit(): void {
|
||||
this.editing = false;
|
||||
}
|
||||
|
||||
saveEdit(): void {
|
||||
if (!this.campaign || !this.editName.trim()) return;
|
||||
this.campaignService.updateCampaign(this.campaign.id!, {
|
||||
name: this.editName.trim(),
|
||||
description: this.editDescription,
|
||||
playerCount: this.campaign.playerCount ?? 0,
|
||||
loreId: this.editLoreId ? this.editLoreId : null
|
||||
}).subscribe({
|
||||
next: (updated) => {
|
||||
this.campaign = updated;
|
||||
this.editing = false;
|
||||
// Recharge pour actualiser le badge "Univers" et le titre sidebar.
|
||||
this.reload(updated.id!);
|
||||
},
|
||||
error: () => console.error('Erreur lors de la mise à jour de la campagne')
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Suppression protégée : refus si la campagne contient des arcs.
|
||||
* Les arcs contiennent potentiellement des chapitres/scènes construits longuement.
|
||||
*/
|
||||
deleteCampaign(): void {
|
||||
if (!this.campaign) return;
|
||||
if (this.arcs.length > 0) {
|
||||
alert(
|
||||
`Impossible de supprimer "${this.campaign.name}" : elle contient encore ${this.arcs.length} arc(s).\n` +
|
||||
`Videz la campagne (arcs et chapitres) avant de la supprimer.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!confirm(`Supprimer définitivement la campagne "${this.campaign.name}" ?`)) return;
|
||||
this.campaignService.deleteCampaign(this.campaign.id!).subscribe({
|
||||
next: () => this.router.navigate(['/campaigns']),
|
||||
error: () => console.error('Erreur lors de la suppression de la campagne')
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.layoutService.hide();
|
||||
}
|
||||
}
|
||||
95
web/src/app/campaigns/campaign-tree.helper.ts
Normal file
95
web/src/app/campaigns/campaign-tree.helper.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { Observable, forkJoin, of } from 'rxjs';
|
||||
import { switchMap, map } from 'rxjs/operators';
|
||||
import { CampaignService } from '../services/campaign.service';
|
||||
import { TreeItem } from '../services/layout.service';
|
||||
import { Arc, Chapter, Scene } from '../services/campaign.model';
|
||||
|
||||
/**
|
||||
* Helper — charge l'arborescence complète d'une campagne (arcs -> chapitres -> scènes)
|
||||
* et la transforme en TreeItem[] pour la secondary sidebar.
|
||||
*
|
||||
* Pourquoi un helper et pas un service ? C'est de la logique de présentation
|
||||
* (mapping REST -> ViewModel de la sidebar), pas du domaine métier.
|
||||
*/
|
||||
|
||||
export interface CampaignTreeData {
|
||||
arcs: Arc[];
|
||||
chaptersByArc: Record<string, Chapter[]>;
|
||||
scenesByChapter: Record<string, Scene[]>;
|
||||
}
|
||||
|
||||
export function loadCampaignTreeData(
|
||||
service: CampaignService,
|
||||
campaignId: string
|
||||
): Observable<CampaignTreeData> {
|
||||
return service.getArcs(campaignId).pipe(
|
||||
switchMap(arcs => {
|
||||
if (arcs.length === 0) {
|
||||
return of({ arcs, chaptersByArc: {}, scenesByChapter: {} });
|
||||
}
|
||||
const chapterCalls = arcs.map(a =>
|
||||
service.getChapters(a.id!).pipe(map(chapters => ({ arcId: a.id!, chapters })))
|
||||
);
|
||||
return forkJoin(chapterCalls).pipe(
|
||||
switchMap(chapterResults => {
|
||||
const chaptersByArc: Record<string, Chapter[]> = {};
|
||||
const allChapters: Chapter[] = [];
|
||||
chapterResults.forEach(r => {
|
||||
chaptersByArc[r.arcId] = r.chapters;
|
||||
allChapters.push(...r.chapters);
|
||||
});
|
||||
|
||||
if (allChapters.length === 0) {
|
||||
return of({ arcs, chaptersByArc, scenesByChapter: {} });
|
||||
}
|
||||
const sceneCalls = allChapters.map(c =>
|
||||
service.getScenes(c.id!).pipe(map(scenes => ({ chapterId: c.id!, scenes })))
|
||||
);
|
||||
return forkJoin(sceneCalls).pipe(
|
||||
map(sceneResults => {
|
||||
const scenesByChapter: Record<string, Scene[]> = {};
|
||||
sceneResults.forEach(r => { scenesByChapter[r.chapterId] = r.scenes; });
|
||||
return { arcs, chaptersByArc, scenesByChapter };
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function buildCampaignTree(campaignId: string, data: CampaignTreeData): TreeItem[] {
|
||||
return data.arcs.map(arc => {
|
||||
const chapterItems: TreeItem[] = (data.chaptersByArc[arc.id!] ?? []).map(ch => {
|
||||
const sceneItems: TreeItem[] = (data.scenesByChapter[ch.id!] ?? []).map(sc => ({
|
||||
id: sc.id!,
|
||||
label: sc.name,
|
||||
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}/scenes/${sc.id}`
|
||||
}));
|
||||
sceneItems.push({
|
||||
id: `new-scene-${ch.id}`,
|
||||
label: '+ Nouvelle scène',
|
||||
isAction: true,
|
||||
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}/scenes/create`
|
||||
});
|
||||
return {
|
||||
id: ch.id!,
|
||||
label: ch.name,
|
||||
children: sceneItems,
|
||||
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}`
|
||||
};
|
||||
});
|
||||
chapterItems.push({
|
||||
id: `new-chapter-${arc.id}`,
|
||||
label: '+ Nouveau chapitre',
|
||||
isAction: true,
|
||||
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/create`
|
||||
});
|
||||
return {
|
||||
id: arc.id!,
|
||||
label: arc.name,
|
||||
children: chapterItems,
|
||||
route: `/campaigns/${campaignId}/arcs/${arc.id}`
|
||||
};
|
||||
});
|
||||
}
|
||||
42
web/src/app/campaigns/campaigns.component.html
Normal file
42
web/src/app/campaigns/campaigns.component.html
Normal file
@@ -0,0 +1,42 @@
|
||||
<div class="campaigns-page">
|
||||
|
||||
<div class="campaigns-hero">
|
||||
<lucide-icon [img]="Map" [size]="56" class="hero-icon"></lucide-icon>
|
||||
<h1>Vos Campagnes</h1>
|
||||
<p class="hero-subtitle">Rejoignez une campagne ou créez-en de nouvelles</p>
|
||||
</div>
|
||||
|
||||
<div class="campaigns-grid">
|
||||
|
||||
<div class="campaign-card" *ngFor="let campaign of campaigns" (click)="navigateToDetail(campaign.id!)">
|
||||
<div class="card-header">
|
||||
<span class="status-badge en-cours">En cours</span>
|
||||
<span class="card-date">{{ campaign.playerCount }} joueurs</span>
|
||||
</div>
|
||||
<h2>{{ campaign.name }}</h2>
|
||||
<p class="card-description">{{ campaign.description }}</p>
|
||||
<div class="card-stats">
|
||||
<span>⚔️ {{ campaign.arcCount || 0 }} arcs</span>
|
||||
<span>📖 {{ campaign.chapterCount || 0 }} chapitres</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="campaign-card card-new" (click)="openCreateModal()">
|
||||
<div class="new-icon">
|
||||
<lucide-icon [img]="Plus" [size]="20"></lucide-icon>
|
||||
</div>
|
||||
<h2>Nouvelle Campagne</h2>
|
||||
<p class="card-description">Créez une nouvelle aventure</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<p class="tip">💡 Astuce : Organisez vos arcs et chapitres pour ne rien oublier de vos aventures</p>
|
||||
|
||||
</div>
|
||||
|
||||
<app-campaign-create
|
||||
*ngIf="showCreateModal"
|
||||
(close)="onModalClose()"
|
||||
(created)="onCampaignCreated($event)">
|
||||
</app-campaign-create>
|
||||
99
web/src/app/campaigns/campaigns.component.scss
Normal file
99
web/src/app/campaigns/campaigns.component.scss
Normal file
@@ -0,0 +1,99 @@
|
||||
.campaigns-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 3rem 2rem;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.campaigns-hero {
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
|
||||
.hero-icon { display: block; margin-bottom: 1rem; color: #6c63ff; }
|
||||
|
||||
h1 { font-size: 2rem; font-weight: 700; color: white; margin-bottom: 0.5rem; }
|
||||
|
||||
.hero-subtitle { color: #6b7280; font-size: 0.95rem; }
|
||||
}
|
||||
|
||||
.campaigns-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1.5rem;
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
.campaign-card {
|
||||
background: #111827;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, transform 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: #6c63ff;
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
|
||||
&.en-cours { background: #1e3a5f; color: #60a5fa; }
|
||||
&.terminée { background: #1a3a2a; color: #4ade80; }
|
||||
&.en-pause { background: #3a2a1a; color: #fb923c; }
|
||||
}
|
||||
|
||||
.card-date { font-size: 0.75rem; color: #6b7280; }
|
||||
|
||||
h2 { color: white; font-size: 1.1rem; margin-bottom: 0.5rem; }
|
||||
|
||||
.card-description { color: #6b7280; font-size: 0.875rem; line-height: 1.5; margin-bottom: 1rem; }
|
||||
|
||||
.card-stats {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.8rem;
|
||||
color: #4b5563;
|
||||
}
|
||||
}
|
||||
|
||||
.card-new {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-style: dashed;
|
||||
border-color: #374151;
|
||||
text-align: center;
|
||||
|
||||
.new-icon {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 50%;
|
||||
background: #1f2937;
|
||||
color: #6c63ff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.tip {
|
||||
margin-top: 3rem;
|
||||
font-size: 0.8rem;
|
||||
color: #4b5563;
|
||||
}
|
||||
60
web/src/app/campaigns/campaigns.component.ts
Normal file
60
web/src/app/campaigns/campaigns.component.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
import { LucideAngularModule, Map, Plus } from 'lucide-angular';
|
||||
import { CampaignService } from '../services/campaign.service';
|
||||
import { Campaign } from '../services/campaign.model';
|
||||
import { CampaignCreateComponent, CampaignCreatePayload } from './campaign-create/campaign-create.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-campaigns',
|
||||
standalone: true,
|
||||
imports: [CommonModule, LucideAngularModule, CampaignCreateComponent],
|
||||
templateUrl: './campaigns.component.html',
|
||||
styleUrls: ['./campaigns.component.scss']
|
||||
})
|
||||
export class CampaignsComponent implements OnInit {
|
||||
readonly Map = Map;
|
||||
readonly Plus = Plus;
|
||||
|
||||
campaigns: Campaign[] = [];
|
||||
showCreateModal = false;
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
private campaignService: CampaignService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadCampaigns();
|
||||
}
|
||||
|
||||
loadCampaigns(): void {
|
||||
this.campaignService.getAllCampaigns().subscribe({
|
||||
next: (data) => this.campaigns = data,
|
||||
error: () => this.campaigns = []
|
||||
});
|
||||
}
|
||||
|
||||
openCreateModal(): void {
|
||||
this.showCreateModal = true;
|
||||
}
|
||||
|
||||
onModalClose(): void {
|
||||
this.showCreateModal = false;
|
||||
}
|
||||
|
||||
onCampaignCreated(data: CampaignCreatePayload): void {
|
||||
this.campaignService.createCampaign(data).subscribe({
|
||||
next: () => {
|
||||
this.showCreateModal = false;
|
||||
this.loadCampaigns();
|
||||
},
|
||||
error: () => console.error('Erreur lors de la création de la campagne')
|
||||
});
|
||||
}
|
||||
|
||||
navigateToDetail(id: string): void {
|
||||
this.router.navigate(['/campaigns', id]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<div class="chapter-create-page">
|
||||
|
||||
<div class="page-header">
|
||||
<h1>Créer un nouveau chapitre</h1>
|
||||
<p class="arc-ref" *ngIf="arcName">Arc : {{ arcName }}</p>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="submit()" class="chapter-form">
|
||||
|
||||
<div class="field">
|
||||
<label>Nom du chapitre *</label>
|
||||
<input
|
||||
type="text"
|
||||
formControlName="name"
|
||||
placeholder="Ex: Chapitre 1: Les Disparitions"
|
||||
[class.invalid]="form.get('name')?.invalid && form.get('name')?.touched"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Description</label>
|
||||
<textarea
|
||||
formControlName="description"
|
||||
placeholder="Décrivez ce chapitre..."
|
||||
rows="5">
|
||||
</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn-primary" [disabled]="form.invalid">
|
||||
Créer le chapitre
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,21 @@
|
||||
.chapter-create-page {
|
||||
padding: 2.5rem 2rem;
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
// Overrides locaux :
|
||||
// - titre en violet (au lieu de blanc comme le .page-header global)
|
||||
// - sous-titre .arc-ref spécifique à cet écran (référence à l'arc parent)
|
||||
.page-header {
|
||||
h1 { color: #a5b4fc; }
|
||||
.arc-ref { color: #6b7280; font-size: 0.85rem; margin: 0; }
|
||||
}
|
||||
|
||||
.chapter-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
// Le bouton "Créer" prend toute la largeur restante.
|
||||
.btn-primary { flex: 1; }
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { forkJoin } from 'rxjs';
|
||||
import { LucideAngularModule } from 'lucide-angular';
|
||||
import { CampaignService } from '../../services/campaign.service';
|
||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||
import { Campaign } from '../../services/campaign.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
||||
|
||||
/**
|
||||
* Écran de création d'un nouveau chapitre rattaché à un arc.
|
||||
* Route : /campaigns/:campaignId/arcs/:arcId/chapters/create
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-chapter-create',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule],
|
||||
templateUrl: './chapter-create.component.html',
|
||||
styleUrls: ['./chapter-create.component.scss']
|
||||
})
|
||||
export class ChapterCreateComponent implements OnInit, OnDestroy {
|
||||
form: FormGroup;
|
||||
campaignId = '';
|
||||
arcId = '';
|
||||
arcName = '';
|
||||
private existingChapterCount = 0;
|
||||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private layoutService: LayoutService
|
||||
) {
|
||||
this.form = this.fb.group({
|
||||
name: ['', Validators.required],
|
||||
description: ['']
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.campaignId = this.route.snapshot.paramMap.get('campaignId')!;
|
||||
this.arcId = this.route.snapshot.paramMap.get('arcId')!;
|
||||
this.loadLayout();
|
||||
}
|
||||
|
||||
private loadLayout(): void {
|
||||
forkJoin({
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
|
||||
}).subscribe(({ campaign, allCampaigns, treeData }) => {
|
||||
const currentArc = treeData.arcs.find(a => a.id === this.arcId);
|
||||
this.arcName = currentArc?.name ?? '';
|
||||
this.existingChapterCount = treeData.chaptersByArc[this.arcId]?.length ?? 0;
|
||||
|
||||
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
|
||||
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
|
||||
}));
|
||||
|
||||
this.layoutService.show({
|
||||
title: campaign.name,
|
||||
items: buildCampaignTree(this.campaignId, treeData),
|
||||
footerLabel: 'Toutes les campagnes',
|
||||
createActions: [
|
||||
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${this.campaignId}/arcs/create` }
|
||||
],
|
||||
globalItems,
|
||||
globalBackLabel: 'Toutes les campagnes',
|
||||
globalBackRoute: '/campaigns'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (this.form.invalid) return;
|
||||
this.campaignService.createChapter({
|
||||
name: this.form.value.name,
|
||||
description: this.form.value.description,
|
||||
arcId: this.arcId,
|
||||
order: this.existingChapterCount + 1
|
||||
}).subscribe({
|
||||
next: () => this.router.navigate(['/campaigns', this.campaignId]),
|
||||
error: () => console.error('Erreur lors de la création du chapitre')
|
||||
});
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.router.navigate(['/campaigns', this.campaignId]);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.layoutService.hide();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<div class="edit-page">
|
||||
|
||||
<div class="page-header">
|
||||
<h1>{{ chapter?.name || 'Chapitre' }}</h1>
|
||||
<p class="subtitle">Chapitre</p>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="submit()" class="edit-form">
|
||||
|
||||
<div class="field">
|
||||
<label>Titre du chapitre *</label>
|
||||
<input
|
||||
type="text"
|
||||
formControlName="name"
|
||||
placeholder="Ex: Chapitre 1: Les Disparitions"
|
||||
[class.invalid]="form.get('name')?.invalid && form.get('name')?.touched"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Synopsis du chapitre</label>
|
||||
<textarea
|
||||
formControlName="description"
|
||||
placeholder="Décrivez brièvement ce qui se passe dans ce chapitre..."
|
||||
rows="5">
|
||||
</textarea>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Notes du Maître de Jeu</label>
|
||||
<textarea
|
||||
formControlName="gmNotes"
|
||||
placeholder="Vos notes privées sur le déroulement du chapitre, les événements clés, les rebondissements..."
|
||||
rows="6">
|
||||
</textarea>
|
||||
<small class="field-hint">Ces notes sont privées et ne seront pas exportées vers FoundryVTT.</small>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label>Objectifs des joueurs</label>
|
||||
<textarea
|
||||
formControlName="playerObjectives"
|
||||
placeholder="Que doivent accomplir les joueurs dans ce chapitre ?"
|
||||
rows="4">
|
||||
</textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Enjeux narratifs</label>
|
||||
<textarea
|
||||
formControlName="narrativeStakes"
|
||||
placeholder="Quels sont les enjeux dramatiques ?"
|
||||
rows="4">
|
||||
</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Pages Lore associées (B2 cross-context) ===== -->
|
||||
<div class="field" *ngIf="loreId">
|
||||
<label>Pages Lore associées</label>
|
||||
<app-lore-link-picker
|
||||
[value]="relatedPageIds"
|
||||
[availablePages]="availablePages"
|
||||
[loreId]="loreId"
|
||||
(valueChange)="relatedPageIds = $event">
|
||||
</app-lore-link-picker>
|
||||
<small class="field-hint">
|
||||
Liez ce chapitre à des PNJ, lieux ou éléments du Lore qui y apparaissent.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="field" *ngIf="!loreId">
|
||||
<small class="field-hint">
|
||||
💡 Cette campagne n'est associée à aucun univers. Associez-la à un Lore dans l'écran de la campagne
|
||||
pour pouvoir lier ce chapitre à des pages du Lore.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn-primary" [disabled]="form.invalid">
|
||||
Sauvegarder
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
|
||||
<button type="button" class="btn-danger" (click)="delete()">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,18 @@
|
||||
.edit-page {
|
||||
padding: 2.5rem 2rem;
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.edit-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
// Override local : bouton Supprimer poussé à droite (voir _buttons.scss pour la base).
|
||||
.btn-danger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
157
web/src/app/campaigns/chapter-edit/chapter-edit.component.ts
Normal file
157
web/src/app/campaigns/chapter-edit/chapter-edit.component.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { switchMap } from 'rxjs/operators';
|
||||
import { LucideAngularModule, Trash2 } from 'lucide-angular';
|
||||
import { CampaignService } from '../../services/campaign.service';
|
||||
import { PageService } from '../../services/page.service';
|
||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||
import { PageTitleService } from '../../services/page-title.service';
|
||||
import { Campaign, Chapter } from '../../services/campaign.model';
|
||||
import { Page } from '../../services/page.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
||||
import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link-picker.component';
|
||||
|
||||
/**
|
||||
* Écran de détail/modification d'un Chapitre.
|
||||
* Route : /campaigns/:campaignId/arcs/:arcId/chapters/:chapterId
|
||||
*
|
||||
* Inclut le picker de pages Lore (B2 cross-context) si la campagne parente
|
||||
* est associée à un Lore.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-chapter-edit',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, LoreLinkPickerComponent],
|
||||
templateUrl: './chapter-edit.component.html',
|
||||
styleUrls: ['./chapter-edit.component.scss']
|
||||
})
|
||||
export class ChapterEditComponent implements OnInit, OnDestroy {
|
||||
readonly Trash2 = Trash2;
|
||||
|
||||
form: FormGroup;
|
||||
campaignId = '';
|
||||
arcId = '';
|
||||
chapterId = '';
|
||||
chapter: Chapter | null = null;
|
||||
|
||||
availablePages: Page[] = [];
|
||||
loreId: string | null = null;
|
||||
relatedPageIds: string[] = [];
|
||||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService
|
||||
) {
|
||||
this.form = this.fb.group({
|
||||
name: ['', Validators.required],
|
||||
description: [''],
|
||||
gmNotes: [''],
|
||||
playerObjectives: [''],
|
||||
narrativeStakes: ['']
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
// On s'abonne à paramMap plutôt que de lire snapshot une fois : Angular
|
||||
// réutilise le composant quand on navigue entre chapitres frères via
|
||||
// l'arbre (même route pattern), et ngOnInit ne se relance pas.
|
||||
this.route.paramMap.subscribe(pm => {
|
||||
const newCampaignId = pm.get('campaignId')!;
|
||||
const newArcId = pm.get('arcId')!;
|
||||
const newChapterId = pm.get('chapterId')!;
|
||||
if (newChapterId !== this.chapterId ||
|
||||
newArcId !== this.arcId ||
|
||||
newCampaignId !== this.campaignId) {
|
||||
this.campaignId = newCampaignId;
|
||||
this.arcId = newArcId;
|
||||
this.chapterId = newChapterId;
|
||||
this.loadAll();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private loadAll(): void {
|
||||
forkJoin({
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
chapter: this.campaignService.getChapterById(this.chapterId),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
|
||||
}).pipe(
|
||||
switchMap(data => {
|
||||
const lid = data.campaign.loreId ?? null;
|
||||
const pages$ = lid ? this.pageService.getByLoreId(lid) : of([] as Page[]);
|
||||
return pages$.pipe(switchMap(pages => of({ ...data, pages, loreId: lid })));
|
||||
})
|
||||
).subscribe(({ campaign, allCampaigns, chapter, treeData, pages, loreId }) => {
|
||||
this.chapter = chapter;
|
||||
this.pageTitleService.set(chapter.name);
|
||||
this.loreId = loreId;
|
||||
this.availablePages = pages;
|
||||
this.relatedPageIds = [...(chapter.relatedPageIds ?? [])];
|
||||
this.form.patchValue({
|
||||
name: chapter.name,
|
||||
description: chapter.description ?? '',
|
||||
gmNotes: chapter.gmNotes ?? '',
|
||||
playerObjectives: chapter.playerObjectives ?? '',
|
||||
narrativeStakes: chapter.narrativeStakes ?? ''
|
||||
});
|
||||
|
||||
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
|
||||
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
|
||||
}));
|
||||
|
||||
this.layoutService.show({
|
||||
title: campaign.name,
|
||||
items: buildCampaignTree(this.campaignId, treeData),
|
||||
footerLabel: 'Toutes les campagnes',
|
||||
createActions: [
|
||||
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${this.campaignId}/arcs/create` }
|
||||
],
|
||||
globalItems,
|
||||
globalBackLabel: 'Toutes les campagnes',
|
||||
globalBackRoute: '/campaigns'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (this.form.invalid || !this.chapter) return;
|
||||
this.campaignService.updateChapter(this.chapterId, {
|
||||
name: this.form.value.name,
|
||||
description: this.form.value.description,
|
||||
arcId: this.arcId,
|
||||
order: this.chapter.order ?? 1,
|
||||
gmNotes: this.form.value.gmNotes,
|
||||
playerObjectives: this.form.value.playerObjectives,
|
||||
narrativeStakes: this.form.value.narrativeStakes,
|
||||
relatedPageIds: this.relatedPageIds
|
||||
}).subscribe({
|
||||
next: () => this.router.navigate(['/campaigns', this.campaignId]),
|
||||
error: () => console.error('Erreur lors de la sauvegarde')
|
||||
});
|
||||
}
|
||||
|
||||
delete(): void {
|
||||
if (!confirm(`Supprimer le chapitre "${this.chapter?.name}" ? Cette action est irréversible.`)) return;
|
||||
this.campaignService.deleteChapter(this.chapterId).subscribe({
|
||||
next: () => this.router.navigate(['/campaigns', this.campaignId]),
|
||||
error: () => console.error('Erreur lors de la suppression')
|
||||
});
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.router.navigate(['/campaigns', this.campaignId]);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.layoutService.hide();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<div class="scene-create-page">
|
||||
|
||||
<div class="page-header">
|
||||
<h1>Créer une nouvelle scène</h1>
|
||||
<p class="chapter-ref" *ngIf="chapterName">Chapitre : {{ chapterName }}</p>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="submit()" class="scene-form">
|
||||
|
||||
<div class="field">
|
||||
<label>Nom de la scène *</label>
|
||||
<input
|
||||
type="text"
|
||||
formControlName="name"
|
||||
placeholder="Ex: Arrivée au village"
|
||||
[class.invalid]="form.get('name')?.invalid && form.get('name')?.touched"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Description</label>
|
||||
<textarea
|
||||
formControlName="description"
|
||||
placeholder="Décrivez la scène, les événements clés, les PNJ présents..."
|
||||
rows="6">
|
||||
</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn-primary" [disabled]="form.invalid">
|
||||
Créer la scène
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,18 @@
|
||||
.scene-create-page {
|
||||
padding: 2.5rem 2rem;
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
// Overrides locaux : titre violet + sous-titre .chapter-ref (parent de la scène).
|
||||
.page-header {
|
||||
h1 { color: #a5b4fc; }
|
||||
.chapter-ref { color: #6b7280; font-size: 0.85rem; margin: 0; }
|
||||
}
|
||||
|
||||
.scene-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.btn-primary { flex: 1; }
|
||||
99
web/src/app/campaigns/scene-create/scene-create.component.ts
Normal file
99
web/src/app/campaigns/scene-create/scene-create.component.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { forkJoin } from 'rxjs';
|
||||
import { LucideAngularModule } from 'lucide-angular';
|
||||
import { CampaignService } from '../../services/campaign.service';
|
||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||
import { Campaign } from '../../services/campaign.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
||||
|
||||
/**
|
||||
* Écran de création d'une nouvelle scène rattachée à un chapitre.
|
||||
* Route : /campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/create
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-scene-create',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule],
|
||||
templateUrl: './scene-create.component.html',
|
||||
styleUrls: ['./scene-create.component.scss']
|
||||
})
|
||||
export class SceneCreateComponent implements OnInit, OnDestroy {
|
||||
form: FormGroup;
|
||||
campaignId = '';
|
||||
arcId = '';
|
||||
chapterId = '';
|
||||
chapterName = '';
|
||||
private existingSceneCount = 0;
|
||||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private layoutService: LayoutService
|
||||
) {
|
||||
this.form = this.fb.group({
|
||||
name: ['', Validators.required],
|
||||
description: ['']
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.campaignId = this.route.snapshot.paramMap.get('campaignId')!;
|
||||
this.arcId = this.route.snapshot.paramMap.get('arcId')!;
|
||||
this.chapterId = this.route.snapshot.paramMap.get('chapterId')!;
|
||||
this.loadLayout();
|
||||
}
|
||||
|
||||
private loadLayout(): void {
|
||||
forkJoin({
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
|
||||
}).subscribe(({ campaign, allCampaigns, treeData }) => {
|
||||
const currentChapter = (treeData.chaptersByArc[this.arcId] ?? []).find(c => c.id === this.chapterId);
|
||||
this.chapterName = currentChapter?.name ?? '';
|
||||
this.existingSceneCount = treeData.scenesByChapter[this.chapterId]?.length ?? 0;
|
||||
|
||||
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
|
||||
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
|
||||
}));
|
||||
|
||||
this.layoutService.show({
|
||||
title: campaign.name,
|
||||
items: buildCampaignTree(this.campaignId, treeData),
|
||||
footerLabel: 'Toutes les campagnes',
|
||||
createActions: [
|
||||
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${this.campaignId}/arcs/create` }
|
||||
],
|
||||
globalItems,
|
||||
globalBackLabel: 'Toutes les campagnes',
|
||||
globalBackRoute: '/campaigns'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (this.form.invalid) return;
|
||||
this.campaignService.createScene({
|
||||
name: this.form.value.name,
|
||||
description: this.form.value.description,
|
||||
chapterId: this.chapterId,
|
||||
order: this.existingSceneCount + 1
|
||||
}).subscribe({
|
||||
next: () => this.router.navigate(['/campaigns', this.campaignId]),
|
||||
error: () => console.error('Erreur lors de la création de la scène')
|
||||
});
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.router.navigate(['/campaigns', this.campaignId]);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.layoutService.hide();
|
||||
}
|
||||
}
|
||||
137
web/src/app/campaigns/scene-edit/scene-edit.component.html
Normal file
137
web/src/app/campaigns/scene-edit/scene-edit.component.html
Normal file
@@ -0,0 +1,137 @@
|
||||
<div class="edit-page">
|
||||
|
||||
<div class="page-header">
|
||||
<h1>{{ scene?.name || 'Scène' }}</h1>
|
||||
<p class="subtitle">Scène</p>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="submit()" class="edit-form">
|
||||
|
||||
<div class="field">
|
||||
<label>Titre de la scène *</label>
|
||||
<input
|
||||
type="text"
|
||||
formControlName="name"
|
||||
placeholder="Ex: Arrivée au village"
|
||||
[class.invalid]="form.get('name')?.invalid && form.get('name')?.touched"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Description courte *</label>
|
||||
<textarea
|
||||
formControlName="description"
|
||||
placeholder="Résumé en une ou deux phrases de ce qui se passe..."
|
||||
rows="3">
|
||||
</textarea>
|
||||
</div>
|
||||
|
||||
<!-- Section : Contexte et ambiance -->
|
||||
<app-expandable-section title="Contexte et ambiance" icon="📍" [initiallyOpen]="true">
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label>Lieu</label>
|
||||
<input type="text" formControlName="location" placeholder="Ex: Taverne du Dragon d'Or" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Moment</label>
|
||||
<input type="text" formControlName="timing" placeholder="Ex: Soir, à la tombée de la nuit" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Ambiance et atmosphère</label>
|
||||
<textarea
|
||||
formControlName="atmosphere"
|
||||
placeholder="Décrivez l'ambiance générale de la scène (sons, odeurs, lumière, émotions...)"
|
||||
rows="4">
|
||||
</textarea>
|
||||
</div>
|
||||
</app-expandable-section>
|
||||
|
||||
<!-- Section : Narration pour les joueurs -->
|
||||
<app-expandable-section title="Narration pour les joueurs" icon="📖">
|
||||
<div class="field">
|
||||
<textarea
|
||||
formControlName="playerNarration"
|
||||
placeholder="Le texte que vous lirez aux joueurs pour planter le décor de cette scène..."
|
||||
rows="6">
|
||||
</textarea>
|
||||
<small class="field-hint">Ce texte peut être lu directement à vos joueurs.</small>
|
||||
</div>
|
||||
</app-expandable-section>
|
||||
|
||||
<!-- Section : Notes et secrets du MJ (privé) -->
|
||||
<app-expandable-section title="Notes et secrets du MJ" icon="🔒" variant="private">
|
||||
<div class="field">
|
||||
<textarea
|
||||
formControlName="gmSecretNotes"
|
||||
placeholder="Informations cachées, indices, éléments secrets que les joueurs ne doivent pas connaître..."
|
||||
rows="5">
|
||||
</textarea>
|
||||
<small class="field-hint">Ces notes sont privées et visibles uniquement par le MJ.</small>
|
||||
</div>
|
||||
</app-expandable-section>
|
||||
|
||||
<!-- Section : Choix et conséquences -->
|
||||
<app-expandable-section title="Choix et conséquences" icon="🔀">
|
||||
<div class="field">
|
||||
<textarea
|
||||
formControlName="choicesConsequences"
|
||||
placeholder="Décrivez les différentes options qui s'offrent aux joueurs et leurs conséquences..."
|
||||
rows="5">
|
||||
</textarea>
|
||||
</div>
|
||||
</app-expandable-section>
|
||||
|
||||
<!-- Section : Combat ou rencontre -->
|
||||
<app-expandable-section title="Combat ou rencontre" icon="⚔️">
|
||||
<div class="field">
|
||||
<label>Difficulté estimée</label>
|
||||
<input type="text" formControlName="combatDifficulty" placeholder="Ex: Moyenne, 3 gobelins niveau 2" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Ennemis et créatures</label>
|
||||
<textarea
|
||||
formControlName="enemies"
|
||||
placeholder="Liste des ennemis présents dans cette scène..."
|
||||
rows="4">
|
||||
</textarea>
|
||||
</div>
|
||||
</app-expandable-section>
|
||||
|
||||
<!-- Section : Pages Lore associées (B2 cross-context) -->
|
||||
<app-expandable-section title="Pages Lore associées" icon="🔗" *ngIf="loreId">
|
||||
<div class="field">
|
||||
<app-lore-link-picker
|
||||
[value]="relatedPageIds"
|
||||
[availablePages]="availablePages"
|
||||
[loreId]="loreId"
|
||||
(valueChange)="relatedPageIds = $event">
|
||||
</app-lore-link-picker>
|
||||
<small class="field-hint">
|
||||
Épinglez ici le lieu, les PNJ ou créatures de cette scène. Cliquez sur un chip pour ouvrir la page.
|
||||
</small>
|
||||
</div>
|
||||
</app-expandable-section>
|
||||
|
||||
<div class="field" *ngIf="!loreId">
|
||||
<small class="field-hint">
|
||||
💡 Cette campagne n'est associée à aucun univers. Associez-la à un Lore dans l'écran de la campagne
|
||||
pour pouvoir épingler des pages du Lore à cette scène.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn-primary" [disabled]="form.invalid">
|
||||
Sauvegarder
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
|
||||
<button type="button" class="btn-danger" (click)="delete()">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
18
web/src/app/campaigns/scene-edit/scene-edit.component.scss
Normal file
18
web/src/app/campaigns/scene-edit/scene-edit.component.scss
Normal file
@@ -0,0 +1,18 @@
|
||||
.edit-page {
|
||||
padding: 2.5rem 2rem;
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
.edit-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
// Override local : bouton Supprimer poussé à droite (voir _buttons.scss pour la base).
|
||||
.btn-danger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
179
web/src/app/campaigns/scene-edit/scene-edit.component.ts
Normal file
179
web/src/app/campaigns/scene-edit/scene-edit.component.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { switchMap } from 'rxjs/operators';
|
||||
import { LucideAngularModule, Trash2 } from 'lucide-angular';
|
||||
import { CampaignService } from '../../services/campaign.service';
|
||||
import { PageService } from '../../services/page.service';
|
||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||
import { PageTitleService } from '../../services/page-title.service';
|
||||
import { Campaign, Scene } from '../../services/campaign.model';
|
||||
import { Page } from '../../services/page.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
||||
import { ExpandableSectionComponent } from '../../shared/expandable-section/expandable-section.component';
|
||||
import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link-picker.component';
|
||||
|
||||
/**
|
||||
* Écran de détail/modification d'une Scène.
|
||||
* Route : /campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/:sceneId
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-scene-edit',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, ExpandableSectionComponent, LoreLinkPickerComponent],
|
||||
templateUrl: './scene-edit.component.html',
|
||||
styleUrls: ['./scene-edit.component.scss']
|
||||
})
|
||||
export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
readonly Trash2 = Trash2;
|
||||
|
||||
form: FormGroup;
|
||||
campaignId = '';
|
||||
arcId = '';
|
||||
chapterId = '';
|
||||
sceneId = '';
|
||||
scene: Scene | null = null;
|
||||
|
||||
availablePages: Page[] = [];
|
||||
loreId: string | null = null;
|
||||
relatedPageIds: string[] = [];
|
||||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private campaignService: CampaignService,
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService
|
||||
) {
|
||||
this.form = this.fb.group({
|
||||
name: ['', Validators.required],
|
||||
description: [''],
|
||||
// Contexte et ambiance
|
||||
location: [''],
|
||||
timing: [''],
|
||||
atmosphere: [''],
|
||||
// Narration
|
||||
playerNarration: [''],
|
||||
// Secrets MJ
|
||||
gmSecretNotes: [''],
|
||||
// Choix
|
||||
choicesConsequences: [''],
|
||||
// Combat
|
||||
combatDifficulty: [''],
|
||||
enemies: ['']
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
// On s'abonne à paramMap plutôt que de lire snapshot une fois : Angular
|
||||
// réutilise le composant quand on navigue entre scènes frères via l'arbre
|
||||
// (même route pattern), et ngOnInit ne se relance pas.
|
||||
this.route.paramMap.subscribe(pm => {
|
||||
const newCampaignId = pm.get('campaignId')!;
|
||||
const newArcId = pm.get('arcId')!;
|
||||
const newChapterId = pm.get('chapterId')!;
|
||||
const newSceneId = pm.get('sceneId')!;
|
||||
if (newSceneId !== this.sceneId ||
|
||||
newChapterId !== this.chapterId ||
|
||||
newArcId !== this.arcId ||
|
||||
newCampaignId !== this.campaignId) {
|
||||
this.campaignId = newCampaignId;
|
||||
this.arcId = newArcId;
|
||||
this.chapterId = newChapterId;
|
||||
this.sceneId = newSceneId;
|
||||
this.loadAll();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private loadAll(): void {
|
||||
forkJoin({
|
||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
scene: this.campaignService.getSceneById(this.sceneId),
|
||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
|
||||
}).pipe(
|
||||
switchMap(data => {
|
||||
const lid = data.campaign.loreId ?? null;
|
||||
const pages$ = lid ? this.pageService.getByLoreId(lid) : of([] as Page[]);
|
||||
return pages$.pipe(switchMap(pages => of({ ...data, pages, loreId: lid })));
|
||||
})
|
||||
).subscribe(({ campaign, allCampaigns, scene, treeData, pages, loreId }) => {
|
||||
this.scene = scene;
|
||||
this.pageTitleService.set(scene.name);
|
||||
this.loreId = loreId;
|
||||
this.availablePages = pages;
|
||||
this.relatedPageIds = [...(scene.relatedPageIds ?? [])];
|
||||
this.form.patchValue({
|
||||
name: scene.name,
|
||||
description: scene.description ?? '',
|
||||
location: scene.location ?? '',
|
||||
timing: scene.timing ?? '',
|
||||
atmosphere: scene.atmosphere ?? '',
|
||||
playerNarration: scene.playerNarration ?? '',
|
||||
gmSecretNotes: scene.gmSecretNotes ?? '',
|
||||
choicesConsequences: scene.choicesConsequences ?? '',
|
||||
combatDifficulty: scene.combatDifficulty ?? '',
|
||||
enemies: scene.enemies ?? ''
|
||||
});
|
||||
|
||||
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
|
||||
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
|
||||
}));
|
||||
|
||||
this.layoutService.show({
|
||||
title: campaign.name,
|
||||
items: buildCampaignTree(this.campaignId, treeData),
|
||||
footerLabel: 'Toutes les campagnes',
|
||||
createActions: [
|
||||
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${this.campaignId}/arcs/create` }
|
||||
],
|
||||
globalItems,
|
||||
globalBackLabel: 'Toutes les campagnes',
|
||||
globalBackRoute: '/campaigns'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (this.form.invalid || !this.scene) return;
|
||||
this.campaignService.updateScene(this.sceneId, {
|
||||
name: this.form.value.name,
|
||||
description: this.form.value.description,
|
||||
chapterId: this.chapterId,
|
||||
order: this.scene.order ?? 1,
|
||||
location: this.form.value.location,
|
||||
timing: this.form.value.timing,
|
||||
atmosphere: this.form.value.atmosphere,
|
||||
playerNarration: this.form.value.playerNarration,
|
||||
gmSecretNotes: this.form.value.gmSecretNotes,
|
||||
choicesConsequences: this.form.value.choicesConsequences,
|
||||
combatDifficulty: this.form.value.combatDifficulty,
|
||||
enemies: this.form.value.enemies,
|
||||
relatedPageIds: this.relatedPageIds
|
||||
}).subscribe({
|
||||
next: () => this.router.navigate(['/campaigns', this.campaignId]),
|
||||
error: () => console.error('Erreur lors de la sauvegarde')
|
||||
});
|
||||
}
|
||||
|
||||
delete(): void {
|
||||
if (!confirm(`Supprimer la scène "${this.scene?.name}" ? Cette action est irréversible.`)) return;
|
||||
this.campaignService.deleteScene(this.sceneId).subscribe({
|
||||
next: () => this.router.navigate(['/campaigns', this.campaignId]),
|
||||
error: () => console.error('Erreur lors de la suppression')
|
||||
});
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.router.navigate(['/campaigns', this.campaignId]);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.layoutService.hide();
|
||||
}
|
||||
}
|
||||
54
web/src/app/lore/lore-create/lore-create.component.html
Normal file
54
web/src/app/lore/lore-create/lore-create.component.html
Normal file
@@ -0,0 +1,54 @@
|
||||
<div class="modal-backdrop" (click)="onCancel()">
|
||||
<div class="modal" (click)="$event.stopPropagation()">
|
||||
|
||||
<div class="modal-header">
|
||||
<h2>Créer un nouveau Lore</h2>
|
||||
<button class="btn-close" (click)="onCancel()">
|
||||
<lucide-icon [img]="X" [size]="18"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="submit()">
|
||||
|
||||
<div class="field">
|
||||
<label>Nom de l'univers *</label>
|
||||
<input
|
||||
type="text"
|
||||
formControlName="name"
|
||||
placeholder="Ex: Royaume des Ombres, Cyberpunk 2157..."
|
||||
[class.invalid]="form.get('name')?.invalid && form.get('name')?.touched"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Description</label>
|
||||
<textarea
|
||||
formControlName="description"
|
||||
placeholder="Décrivez brièvement votre univers, son ambiance, son genre..."
|
||||
rows="5"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<p><strong>💡 Astuce :</strong> Votre lore sera créé avec quelques templates par défaut :</p>
|
||||
<ul>
|
||||
<li>PNJ - Pour vos personnages</li>
|
||||
<li>Lieu - Pour vos villes et régions</li>
|
||||
<li>Faction - Pour vos organisations</li>
|
||||
<li>Objet - Pour vos artefacts</li>
|
||||
</ul>
|
||||
<p class="info-footer">Vous pourrez créer vos propres templates ensuite !</p>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="submit" class="btn-primary" [disabled]="form.invalid">
|
||||
<lucide-icon [img]="BookCopy" [size]="16"></lucide-icon>
|
||||
Créer le lore
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="onCancel()">Annuler</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
123
web/src/app/lore/lore-create/lore-create.component.scss
Normal file
123
web/src/app/lore/lore-create/lore-create.component.scss
Normal file
@@ -0,0 +1,123 @@
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: #111827;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
|
||||
h2 { color: white; font-size: 1.25rem; font-weight: 600; }
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover { color: white; }
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
color: #d1d5db;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
input, textarea {
|
||||
width: 100%;
|
||||
background: #1f2937;
|
||||
border: 1px solid #374151;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1rem;
|
||||
color: white;
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
resize: none;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&::placeholder { color: #4b5563; }
|
||||
&:focus { border-color: #6c63ff; }
|
||||
&.invalid { border-color: #ef4444; }
|
||||
}
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: #1f2937;
|
||||
border-radius: 8px;
|
||||
padding: 1rem 1.25rem;
|
||||
margin-bottom: 2rem;
|
||||
font-size: 0.875rem;
|
||||
color: #9ca3af;
|
||||
line-height: 1.6;
|
||||
|
||||
ul {
|
||||
margin: 0.5rem 0 0.5rem 1.25rem;
|
||||
li { margin-bottom: 0.15rem; }
|
||||
}
|
||||
|
||||
.info-footer { color: #6b7280; margin-top: 0.5rem; }
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #6c63ff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover:not(:disabled) { background: #5b52e0; }
|
||||
&:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #1f2937;
|
||||
color: #d1d5db;
|
||||
border: 1px solid #374151;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover { background: #374151; }
|
||||
}
|
||||
37
web/src/app/lore/lore-create/lore-create.component.ts
Normal file
37
web/src/app/lore/lore-create/lore-create.component.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Component, EventEmitter, Output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { LucideAngularModule, BookCopy, X } from 'lucide-angular';
|
||||
|
||||
@Component({
|
||||
selector: 'app-lore-create',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule],
|
||||
templateUrl: './lore-create.component.html',
|
||||
styleUrls: ['./lore-create.component.scss']
|
||||
})
|
||||
export class LoreCreateComponent {
|
||||
@Output() close = new EventEmitter<void>();
|
||||
@Output() created = new EventEmitter<{ name: string; description: string }>();
|
||||
|
||||
readonly BookCopy = BookCopy;
|
||||
readonly X = X;
|
||||
|
||||
form: FormGroup;
|
||||
|
||||
constructor(private fb: FormBuilder) {
|
||||
this.form = this.fb.group({
|
||||
name: ['', Validators.required],
|
||||
description: ['']
|
||||
});
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (this.form.invalid) return;
|
||||
this.created.emit(this.form.value);
|
||||
}
|
||||
|
||||
onCancel(): void {
|
||||
this.close.emit();
|
||||
}
|
||||
}
|
||||
70
web/src/app/lore/lore-detail/lore-detail.component.html
Normal file
70
web/src/app/lore/lore-detail/lore-detail.component.html
Normal file
@@ -0,0 +1,70 @@
|
||||
<div class="lore-detail" *ngIf="lore">
|
||||
|
||||
<!-- ============ Header : mode lecture ============ -->
|
||||
<div class="detail-header" *ngIf="!editing">
|
||||
<div class="header-texts">
|
||||
<h1>{{ lore.name }}</h1>
|
||||
<p class="description">{{ lore.description }}</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="btn-secondary" (click)="startEdit()" title="Modifier le Lore">
|
||||
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
|
||||
Modifier
|
||||
</button>
|
||||
<button type="button" class="btn-danger" (click)="deleteLore()" title="Supprimer le Lore">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ Header : mode édition inline ============ -->
|
||||
<div class="detail-header edit-mode" *ngIf="editing">
|
||||
<div class="field">
|
||||
<label>Nom</label>
|
||||
<input type="text" [(ngModel)]="editName" name="editName" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Description</label>
|
||||
<textarea [(ngModel)]="editDescription" name="editDescription" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="btn-primary" (click)="saveEdit()" [disabled]="!editName.trim()">
|
||||
Sauvegarder
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="cancelEdit()">
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ Grille des dossiers racine ============ -->
|
||||
<div class="nodes-section">
|
||||
<div class="section-header">
|
||||
<h2>Dossiers</h2>
|
||||
<button class="btn-add" (click)="navigateToCreateNode()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
Nouveau dossier
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- rootNodes : uniquement les dossiers racine (pas les sous-dossiers,
|
||||
qui sont visibles dans l'arbre de la sidebar). -->
|
||||
<div class="nodes-grid" *ngIf="rootNodes.length > 0">
|
||||
<div class="node-card" *ngFor="let node of rootNodes" (click)="navigateToFolder(node.id!)">
|
||||
<lucide-icon [img]="Folder" [size]="24" class="node-icon"></lucide-icon>
|
||||
<span class="node-name">{{ node.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="empty-state" *ngIf="rootNodes.length === 0">
|
||||
<lucide-icon [img]="Folder" [size]="40" class="empty-icon"></lucide-icon>
|
||||
<p>Aucun dossier pour le moment.</p>
|
||||
<button class="btn-add-first" (click)="navigateToCreateNode()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
Créer votre premier dossier
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
160
web/src/app/lore/lore-detail/lore-detail.component.scss
Normal file
160
web/src/app/lore/lore-detail/lore-detail.component.scss
Normal file
@@ -0,0 +1,160 @@
|
||||
.lore-detail {
|
||||
padding: 2.5rem 2rem;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2.5rem;
|
||||
|
||||
.header-texts { flex: 1; min-width: 0; }
|
||||
|
||||
h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: #6b7280;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// Variante mode édition : input / textarea en colonne.
|
||||
&.edit-mode {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
label { color: #9ca3af; font-size: 0.8rem; font-weight: 500; }
|
||||
input, textarea {
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
padding: 0.6rem 0.85rem;
|
||||
font-size: 0.9rem;
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
|
||||
&:focus { outline: none; border-color: #6c63ff; }
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions { justify-content: flex-end; }
|
||||
}
|
||||
}
|
||||
|
||||
// Boutons partagés du header.
|
||||
.btn-primary, .btn-secondary, .btn-danger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
.btn-primary { background: #6c63ff; color: white; &:hover:not(:disabled) { background: #5b52e0; } }
|
||||
.btn-secondary { background: #1f2937; color: #d1d5db; &:hover:not(:disabled) { background: #374151; } }
|
||||
.btn-danger { background: #3a1e1e; color: #f87171; &:hover:not(:disabled) { background: #5a2e2e; } }
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
h2 { color: #d1d5db; font-size: 1rem; font-weight: 600; }
|
||||
}
|
||||
|
||||
.btn-add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: #6c63ff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover { background: #5b52e0; }
|
||||
}
|
||||
|
||||
.nodes-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.node-card {
|
||||
background: #111827;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 10px;
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, transform 0.2s;
|
||||
|
||||
&:hover { border-color: #6c63ff; transform: translateY(-2px); }
|
||||
|
||||
.node-icon { color: #6c63ff; }
|
||||
.node-name { color: white; font-size: 0.9rem; font-weight: 600; }
|
||||
.node-type { color: #6b7280; font-size: 0.75rem; }
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 4rem 2rem;
|
||||
color: #6b7280;
|
||||
|
||||
.empty-icon { color: #374151; }
|
||||
p { font-size: 0.95rem; }
|
||||
}
|
||||
|
||||
.btn-add-first {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.6rem 1.25rem;
|
||||
background: #6c63ff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover { background: #5b52e0; }
|
||||
}
|
||||
137
web/src/app/lore/lore-detail/lore-detail.component.ts
Normal file
137
web/src/app/lore/lore-detail/lore-detail.component.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { LucideAngularModule, Folder, Plus, Pencil, Trash2 } from 'lucide-angular';
|
||||
import { LoreService } from '../../services/lore.service';
|
||||
import { TemplateService } from '../../services/template.service';
|
||||
import { PageService } from '../../services/page.service';
|
||||
import { LayoutService } from '../../services/layout.service';
|
||||
import { PageTitleService } from '../../services/page-title.service';
|
||||
import { Lore, LoreNode } from '../../services/lore.model';
|
||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||
|
||||
@Component({
|
||||
selector: 'app-lore-detail',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, LucideAngularModule],
|
||||
templateUrl: './lore-detail.component.html',
|
||||
styleUrls: ['./lore-detail.component.scss']
|
||||
})
|
||||
export class LoreDetailComponent implements OnInit, OnDestroy {
|
||||
readonly Folder = Folder;
|
||||
readonly Plus = Plus;
|
||||
readonly Pencil = Pencil;
|
||||
readonly Trash2 = Trash2;
|
||||
|
||||
lore: Lore | null = null;
|
||||
/** Tous les dossiers du Lore (racines + enfants). */
|
||||
allNodes: LoreNode[] = [];
|
||||
/** Uniquement les dossiers racine — seuls affichés dans la grille principale. */
|
||||
rootNodes: LoreNode[] = [];
|
||||
|
||||
/** Mode édition inline du header (nom + description). */
|
||||
editing = false;
|
||||
editName = '';
|
||||
editDescription = '';
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private loreService: LoreService,
|
||||
private templateService: TemplateService,
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
// On s'abonne à paramMap (pas snapshot) pour recharger quand on switche
|
||||
// d'un Lore à l'autre via la liste globale de la sidebar — Angular réutilise
|
||||
// le même composant et ngOnInit ne se relance pas tout seul.
|
||||
this.route.paramMap.subscribe(pm => {
|
||||
const id = pm.get('id');
|
||||
if (id && id !== this.lore?.id) {
|
||||
this.load(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private load(id: string): void {
|
||||
loadLoreSidebarData(id, this.loreService, this.templateService, this.pageService).subscribe(data => {
|
||||
this.lore = data.lore;
|
||||
this.allNodes = data.nodes;
|
||||
// Bug d'affichage corrigé : on ne liste ici que les dossiers racine
|
||||
// (les sous-dossiers apparaissent dans l'arbre de la sidebar quand on
|
||||
// ouvre leur parent). parentId null OU chaîne vide = racine.
|
||||
this.rootNodes = data.nodes.filter(n => !n.parentId);
|
||||
this.layoutService.show(buildLoreSidebarConfig(data));
|
||||
this.pageTitleService.set(data.lore.name);
|
||||
// On sort du mode édition si on change de Lore en cours d'édition.
|
||||
this.editing = false;
|
||||
});
|
||||
}
|
||||
|
||||
navigateToCreateNode(): void {
|
||||
this.router.navigate(['/lore', this.lore!.id, 'nodes', 'create']);
|
||||
}
|
||||
|
||||
navigateToFolder(nodeId: string): void {
|
||||
this.router.navigate(['/lore', this.lore!.id, 'folders', nodeId, 'edit']);
|
||||
}
|
||||
|
||||
// ─────────────── Édition / suppression du Lore ───────────────
|
||||
|
||||
startEdit(): void {
|
||||
if (!this.lore) return;
|
||||
this.editName = this.lore.name;
|
||||
this.editDescription = this.lore.description ?? '';
|
||||
this.editing = true;
|
||||
}
|
||||
|
||||
cancelEdit(): void {
|
||||
this.editing = false;
|
||||
}
|
||||
|
||||
saveEdit(): void {
|
||||
if (!this.lore || !this.editName.trim()) return;
|
||||
this.loreService.updateLore(this.lore.id!, {
|
||||
name: this.editName.trim(),
|
||||
description: this.editDescription
|
||||
}).subscribe({
|
||||
next: (updated) => {
|
||||
this.lore = updated;
|
||||
this.editing = false;
|
||||
// Recharge la sidebar pour que le titre soit à jour.
|
||||
this.load(updated.id!);
|
||||
},
|
||||
error: () => console.error('Erreur lors de la mise à jour du Lore')
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Suppression protégée : refus si le Lore contient encore des dossiers
|
||||
* ou des pages. Protège contre un clic accidentel sur des données
|
||||
* construites longuement. Logique côté frontend (pas d'appel HTTP
|
||||
* supplémentaire) car les données sont déjà chargées.
|
||||
*/
|
||||
deleteLore(): void {
|
||||
if (!this.lore) return;
|
||||
if (this.allNodes.length > 0) {
|
||||
alert(
|
||||
`Impossible de supprimer "${this.lore.name}" : il contient encore ${this.allNodes.length} dossier(s).\n` +
|
||||
`Videz le Lore (dossiers et pages) avant de le supprimer.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!confirm(`Supprimer définitivement le Lore "${this.lore.name}" ?`)) return;
|
||||
this.loreService.deleteLore(this.lore.id!).subscribe({
|
||||
next: () => this.router.navigate(['/lore']),
|
||||
error: () => console.error('Erreur lors de la suppression du Lore')
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.layoutService.hide();
|
||||
}
|
||||
}
|
||||
53
web/src/app/lore/lore-icons.ts
Normal file
53
web/src/app/lore/lore-icons.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
Folder,
|
||||
Users, Swords, MapPin, Shield, Crown, Skull, Gem,
|
||||
BookOpen, Scroll, Wand2, Sparkles, TreePine, Mountain,
|
||||
Ship, Flame, Star, Moon, Key, Globe, Compass, LucideIconData
|
||||
} from 'lucide-angular';
|
||||
|
||||
/**
|
||||
* Registre partagé d'icônes disponibles pour les dossiers (LoreNode).
|
||||
*
|
||||
* Utilisé à la fois par :
|
||||
* - l'écran de création de dossier (grille de sélection)
|
||||
* - la sidebar (résolution `iconKey → LucideIconData` pour afficher l'icône)
|
||||
*
|
||||
* Pourquoi factoriser ? Avant : deux sources de vérité risqueraient de
|
||||
* diverger (ex: ajout d'une icône dans l'un sans l'autre).
|
||||
*/
|
||||
export interface IconOption {
|
||||
key: string;
|
||||
icon: LucideIconData;
|
||||
}
|
||||
|
||||
export const LORE_ICON_OPTIONS: IconOption[] = [
|
||||
{ key: 'users', icon: Users },
|
||||
{ key: 'swords', icon: Swords },
|
||||
{ key: 'map-pin', icon: MapPin },
|
||||
{ key: 'shield', icon: Shield },
|
||||
{ key: 'crown', icon: Crown },
|
||||
{ key: 'skull', icon: Skull },
|
||||
{ key: 'gem', icon: Gem },
|
||||
{ key: 'book-open', icon: BookOpen },
|
||||
{ key: 'scroll', icon: Scroll },
|
||||
{ key: 'wand', icon: Wand2 },
|
||||
{ key: 'sparkles', icon: Sparkles },
|
||||
{ key: 'tree', icon: TreePine },
|
||||
{ key: 'mountain', icon: Mountain },
|
||||
{ key: 'ship', icon: Ship },
|
||||
{ key: 'flame', icon: Flame },
|
||||
{ key: 'star', icon: Star },
|
||||
{ key: 'moon', icon: Moon },
|
||||
{ key: 'key', icon: Key },
|
||||
{ key: 'globe', icon: Globe },
|
||||
{ key: 'compass', icon: Compass },
|
||||
];
|
||||
|
||||
/** Icône par défaut pour un dossier sans icône. */
|
||||
export const DEFAULT_FOLDER_ICON: LucideIconData = Folder;
|
||||
|
||||
/** Résout une clé d'icône en LucideIconData. Fallback : icône dossier par défaut. */
|
||||
export function resolveIcon(key: string | null | undefined): LucideIconData {
|
||||
if (!key) return DEFAULT_FOLDER_ICON;
|
||||
return LORE_ICON_OPTIONS.find(o => o.key === key)?.icon ?? DEFAULT_FOLDER_ICON;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<div class="node-create-page">
|
||||
|
||||
<div class="page-header">
|
||||
<h1>Créer un nouveau dossier</h1>
|
||||
<p class="subtitle">Les dossiers permettent d'organiser vos pages par catégorie</p>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="submit()" class="node-form">
|
||||
|
||||
<div class="field">
|
||||
<label>Nom du dossier *</label>
|
||||
<input
|
||||
type="text"
|
||||
formControlName="name"
|
||||
placeholder="Ex: Personnages, Créatures..."
|
||||
[class.invalid]="form.get('name')?.invalid && form.get('name')?.touched"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Dossier parent <span class="optional">(optionnel)</span></label>
|
||||
<select formControlName="parentId">
|
||||
<option value="">— Racine du Lore —</option>
|
||||
<option *ngFor="let parent of availableParents" [value]="parent.id">{{ parent.name }}</option>
|
||||
</select>
|
||||
<p class="hint">Laissez vide pour créer un dossier à la racine du lore</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Icône</label>
|
||||
<div class="icon-grid">
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
*ngFor="let option of iconOptions"
|
||||
[class.selected]="selectedIcon === option.key"
|
||||
(click)="selectIcon(option.key)">
|
||||
<lucide-icon [img]="option.icon" [size]="18"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Description <span class="optional">(optionnel)</span></label>
|
||||
<textarea
|
||||
formControlName="description"
|
||||
placeholder="Décrivez le type de contenu que ce dossier contiendra..."
|
||||
rows="4">
|
||||
</textarea>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Adresse</label>
|
||||
<input
|
||||
type="text"
|
||||
formControlName="address"
|
||||
placeholder="nom-du-dossier"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn-primary" [disabled]="form.invalid">
|
||||
<lucide-icon [img]="getIcon(selectedIcon)" [size]="16"></lucide-icon>
|
||||
Créer le dossier
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,116 @@
|
||||
.node-create-page {
|
||||
padding: 2.5rem 2rem;
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 2.5rem;
|
||||
|
||||
h1 { font-size: 1.5rem; font-weight: 700; color: white; margin-bottom: 0.4rem; }
|
||||
.subtitle { color: #6b7280; font-size: 0.9rem; }
|
||||
}
|
||||
|
||||
.node-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
label {
|
||||
font-size: 0.875rem;
|
||||
color: #d1d5db;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.optional { color: #6b7280; font-weight: 400; }
|
||||
|
||||
input, textarea, select {
|
||||
background: #1f2937;
|
||||
border: 1px solid #374151;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1rem;
|
||||
color: white;
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
resize: none;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&::placeholder { color: #4b5563; }
|
||||
&:focus { border-color: #6c63ff; }
|
||||
&.invalid { border-color: #ef4444; }
|
||||
}
|
||||
}
|
||||
|
||||
.icon-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
background: #1f2937;
|
||||
border: 1px solid #374151;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover { background: #374151; color: white; }
|
||||
|
||||
&.selected {
|
||||
background: #1e1b4b;
|
||||
border-color: #6c63ff;
|
||||
color: #a5b4fc;
|
||||
}
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #6c63ff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover:not(:disabled) { background: #5b52e0; }
|
||||
&:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #1f2937;
|
||||
color: #d1d5db;
|
||||
border: 1px solid #374151;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover { background: #374151; }
|
||||
}
|
||||
104
web/src/app/lore/lore-node-create/lore-node-create.component.ts
Normal file
104
web/src/app/lore/lore-node-create/lore-node-create.component.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { LucideAngularModule, LucideIconData } from 'lucide-angular';
|
||||
import { LoreService } from '../../services/lore.service';
|
||||
import { TemplateService } from '../../services/template.service';
|
||||
import { PageService } from '../../services/page.service';
|
||||
import { LayoutService } from '../../services/layout.service';
|
||||
import { LoreNode } from '../../services/lore.model';
|
||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||
import { LORE_ICON_OPTIONS, IconOption, resolveIcon } from '../lore-icons';
|
||||
|
||||
@Component({
|
||||
selector: 'app-lore-node-create',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule],
|
||||
templateUrl: './lore-node-create.component.html',
|
||||
styleUrls: ['./lore-node-create.component.scss']
|
||||
})
|
||||
export class LoreNodeCreateComponent implements OnInit, OnDestroy {
|
||||
|
||||
readonly iconOptions: IconOption[] = LORE_ICON_OPTIONS;
|
||||
|
||||
form: FormGroup;
|
||||
loreId = '';
|
||||
/** parentId optionnel depuis la route — si présent, pré-remplit le champ. */
|
||||
preselectedParentId: string | null = null;
|
||||
/** Liste des dossiers existants pour le select "Dossier parent". */
|
||||
availableParents: LoreNode[] = [];
|
||||
selectedIcon = this.iconOptions[0].key;
|
||||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private loreService: LoreService,
|
||||
private templateService: TemplateService,
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService
|
||||
) {
|
||||
this.form = this.fb.group({
|
||||
name: ['', Validators.required],
|
||||
description: [''],
|
||||
address: ['', Validators.required],
|
||||
parentId: [''] // '' = racine
|
||||
});
|
||||
|
||||
// Auto-génère l'adresse depuis le nom
|
||||
this.form.get('name')!.valueChanges.subscribe(name => {
|
||||
const slug = (name as string).toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
|
||||
this.form.get('address')!.setValue(slug, { emitEvent: false });
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loreId = this.route.snapshot.paramMap.get('loreId')!;
|
||||
this.preselectedParentId = this.route.snapshot.paramMap.get('parentId');
|
||||
this.loadLayout();
|
||||
}
|
||||
|
||||
private loadLayout(): void {
|
||||
loadLoreSidebarData(this.loreId, this.loreService, this.templateService, this.pageService)
|
||||
.subscribe(data => {
|
||||
this.availableParents = data.nodes;
|
||||
if (this.preselectedParentId) {
|
||||
this.form.patchValue({ parentId: this.preselectedParentId });
|
||||
}
|
||||
this.layoutService.show(buildLoreSidebarConfig(data));
|
||||
});
|
||||
}
|
||||
|
||||
selectIcon(key: string): void {
|
||||
this.selectedIcon = key;
|
||||
}
|
||||
|
||||
getIcon(key: string): LucideIconData {
|
||||
return this.iconOptions.find(o => o.key === key)!.icon;
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (this.form.invalid) return;
|
||||
const raw = this.form.value;
|
||||
this.loreService.createLoreNode({
|
||||
name: raw.name,
|
||||
description: raw.description,
|
||||
address: raw.address,
|
||||
icon: this.selectedIcon,
|
||||
parentId: raw.parentId && raw.parentId !== '' ? raw.parentId : null,
|
||||
loreId: this.loreId
|
||||
}).subscribe({
|
||||
next: () => this.router.navigate(['/lore', this.loreId]),
|
||||
error: () => console.error('Erreur lors de la création du dossier')
|
||||
});
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.router.navigate(['/lore', this.loreId]);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.layoutService.hide();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<div class="page" *ngIf="node">
|
||||
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>Éditer le dossier</h1>
|
||||
<p class="subtitle">
|
||||
{{ childFolderCount }} sous-dossier(s) · {{ pageCount }} page(s)
|
||||
</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-danger"
|
||||
[disabled]="!canDelete"
|
||||
[title]="canDelete ? 'Supprimer le dossier' : 'Impossible : le dossier contient des éléments'"
|
||||
(click)="delete()">
|
||||
Supprimer
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary"
|
||||
[disabled]="form.invalid"
|
||||
(click)="save()">
|
||||
Sauvegarder
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form [formGroup]="form" class="edit-form">
|
||||
|
||||
<div class="field">
|
||||
<label>Nom du dossier *</label>
|
||||
<input type="text" formControlName="name" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Dossier parent <span class="optional">(optionnel)</span></label>
|
||||
<select formControlName="parentId">
|
||||
<option value="">— Racine du Lore —</option>
|
||||
<option *ngFor="let parent of availableParents" [value]="parent.id">{{ parent.name }}</option>
|
||||
</select>
|
||||
<p class="hint">Vous ne pouvez pas choisir un sous-dossier du dossier courant (cycle interdit)</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Icône</label>
|
||||
<div class="icon-grid">
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
*ngFor="let option of iconOptions"
|
||||
[class.selected]="selectedIcon === option.key"
|
||||
(click)="selectIcon(option.key)">
|
||||
<lucide-icon [img]="option.icon" [size]="18"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box" *ngIf="!canDelete">
|
||||
⚠️ Pour supprimer ce dossier, videz-le d'abord : déplacez ou supprimez ses
|
||||
{{ childFolderCount }} sous-dossier(s) et ses {{ pageCount }} page(s).
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
141
web/src/app/lore/lore-node-edit/lore-node-edit.component.scss
Normal file
141
web/src/app/lore/lore-node-edit/lore-node-edit.component.scss
Normal file
@@ -0,0 +1,141 @@
|
||||
.page {
|
||||
padding: 2rem 3rem;
|
||||
max-width: 860px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 2rem;
|
||||
|
||||
h1 {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
.subtitle {
|
||||
color: #9ca3af;
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
.edit-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
|
||||
label {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.optional { color: #6b7280; font-weight: 400; }
|
||||
|
||||
input, select {
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
color: white;
|
||||
padding: 0.7rem 0.9rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #6c63ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.76rem;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.icon-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(10, 1fr);
|
||||
gap: 0.5rem;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
border-radius: 6px;
|
||||
color: #d1d5db;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
|
||||
&:hover { border-color: #3a3a55; background: #20203a; }
|
||||
|
||||
&.selected {
|
||||
border-color: #6c63ff;
|
||||
background: #1e1c3a;
|
||||
color: #a5b4fc;
|
||||
}
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
border-left: 3px solid #fca5a5;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
color: #d1d5db;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.btn-primary, .btn-secondary, .btn-danger {
|
||||
padding: 0.6rem 1.1rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #6c63ff;
|
||||
color: white;
|
||||
|
||||
&:hover:not(:disabled) { background: #5a52e0; }
|
||||
&:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #2a2a3d;
|
||||
color: #d1d5db;
|
||||
|
||||
&:hover { background: #363650; }
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #3f1f1f;
|
||||
color: #fca5a5;
|
||||
|
||||
&:hover:not(:disabled) { background: #5a2a2a; }
|
||||
&:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
}
|
||||
163
web/src/app/lore/lore-node-edit/lore-node-edit.component.ts
Normal file
163
web/src/app/lore/lore-node-edit/lore-node-edit.component.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { forkJoin } from 'rxjs';
|
||||
import { LucideAngularModule, LucideIconData } from 'lucide-angular';
|
||||
import { LoreService } from '../../services/lore.service';
|
||||
import { TemplateService } from '../../services/template.service';
|
||||
import { PageService } from '../../services/page.service';
|
||||
import { LayoutService } from '../../services/layout.service';
|
||||
import { PageTitleService } from '../../services/page-title.service';
|
||||
import { LoreNode } from '../../services/lore.model';
|
||||
import { Page } from '../../services/page.model';
|
||||
import {
|
||||
loadLoreSidebarData,
|
||||
buildLoreSidebarConfig,
|
||||
collectDescendantIds
|
||||
} from '../lore-sidebar.helper';
|
||||
import { LORE_ICON_OPTIONS, IconOption } from '../lore-icons';
|
||||
|
||||
/**
|
||||
* Écran d'édition d'un dossier (LoreNode) existant.
|
||||
*
|
||||
* Fonctionnalités :
|
||||
* - Renommer
|
||||
* - Changer l'icône
|
||||
* - Déplacer dans un autre dossier parent (ou vers la racine)
|
||||
* - Supprimer (refusé si le dossier contient des sous-dossiers ou des pages)
|
||||
*
|
||||
* Prévention des cycles : le select "Dossier parent" exclut le dossier en cours
|
||||
* d'édition ET tous ses descendants — sinon l'arbre deviendrait circulaire.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-lore-node-edit',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule],
|
||||
templateUrl: './lore-node-edit.component.html',
|
||||
styleUrls: ['./lore-node-edit.component.scss']
|
||||
})
|
||||
export class LoreNodeEditComponent implements OnInit, OnDestroy {
|
||||
|
||||
readonly iconOptions: IconOption[] = LORE_ICON_OPTIONS;
|
||||
|
||||
form: FormGroup;
|
||||
loreId = '';
|
||||
folderId = '';
|
||||
node: LoreNode | null = null;
|
||||
/** Dossiers proposables comme parent (tous sauf soi-même + descendants). */
|
||||
availableParents: LoreNode[] = [];
|
||||
/** Nombre de sous-dossiers directs (pour affichage + validation de suppression). */
|
||||
childFolderCount = 0;
|
||||
/** Nombre de pages dans ce dossier (pour affichage + validation de suppression). */
|
||||
pageCount = 0;
|
||||
|
||||
selectedIcon: string | null = null;
|
||||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private loreService: LoreService,
|
||||
private templateService: TemplateService,
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService
|
||||
) {
|
||||
this.form = this.fb.group({
|
||||
name: ['', Validators.required],
|
||||
parentId: ['']
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loreId = this.route.snapshot.paramMap.get('loreId')!;
|
||||
this.folderId = this.route.snapshot.paramMap.get('folderId')!;
|
||||
|
||||
// Réagir aux changements de :folderId (navigation entre dossiers dans la sidebar
|
||||
// sans démonter le composant).
|
||||
this.route.paramMap.subscribe(pm => {
|
||||
const newId = pm.get('folderId')!;
|
||||
if (newId !== this.folderId) {
|
||||
this.folderId = newId;
|
||||
this.load();
|
||||
}
|
||||
});
|
||||
|
||||
this.load();
|
||||
}
|
||||
|
||||
private load(): void {
|
||||
forkJoin({
|
||||
sidebar: loadLoreSidebarData(this.loreId, this.loreService, this.templateService, this.pageService),
|
||||
node: this.loreService.getLoreNodeById(this.folderId)
|
||||
}).subscribe(({ sidebar, node }) => {
|
||||
this.layoutService.show(buildLoreSidebarConfig(sidebar));
|
||||
this.hydrate(node, sidebar.nodes, sidebar.pages);
|
||||
});
|
||||
}
|
||||
|
||||
private hydrate(node: LoreNode, allNodes: LoreNode[], allPages: Page[]): void {
|
||||
this.node = node;
|
||||
this.selectedIcon = node.icon ?? null;
|
||||
this.form.patchValue({
|
||||
name: node.name,
|
||||
parentId: node.parentId ?? ''
|
||||
});
|
||||
|
||||
// Liste des parents autorisés : tous les dossiers sauf soi + descendants.
|
||||
const excluded = collectDescendantIds(node.id!, allNodes);
|
||||
this.availableParents = allNodes.filter(n => !excluded.has(n.id!));
|
||||
|
||||
// Stats pour affichage + règle de suppression.
|
||||
this.childFolderCount = allNodes.filter(n => n.parentId === node.id).length;
|
||||
this.pageCount = allPages.filter(p => p.nodeId === node.id).length;
|
||||
this.pageTitleService.set(node.name);
|
||||
}
|
||||
|
||||
selectIcon(key: string): void {
|
||||
this.selectedIcon = key;
|
||||
}
|
||||
|
||||
save(): void {
|
||||
if (this.form.invalid || !this.node) return;
|
||||
const raw = this.form.value;
|
||||
const updated: LoreNode = {
|
||||
...this.node,
|
||||
name: raw.name,
|
||||
icon: this.selectedIcon,
|
||||
parentId: raw.parentId && raw.parentId !== '' ? raw.parentId : null
|
||||
};
|
||||
this.loreService.updateLoreNode(this.folderId, updated).subscribe({
|
||||
next: () => this.router.navigate(['/lore', this.loreId]),
|
||||
error: () => console.error('Erreur lors de la sauvegarde du dossier')
|
||||
});
|
||||
}
|
||||
|
||||
get canDelete(): boolean {
|
||||
return this.childFolderCount === 0 && this.pageCount === 0;
|
||||
}
|
||||
|
||||
delete(): void {
|
||||
if (!this.canDelete || !this.node) return;
|
||||
if (!confirm(`Supprimer le dossier "${this.node.name}" ?`)) return;
|
||||
this.loreService.deleteLoreNode(this.folderId).subscribe({
|
||||
next: () => this.router.navigate(['/lore', this.loreId]),
|
||||
error: () => console.error('Erreur lors de la suppression du dossier')
|
||||
});
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.router.navigate(['/lore', this.loreId]);
|
||||
}
|
||||
|
||||
/** Retourne l'icône lucide à afficher dans l'aperçu du bouton "Sauvegarder". */
|
||||
getIcon(key: string | null): LucideIconData | null {
|
||||
if (!key) return null;
|
||||
return this.iconOptions.find(o => o.key === key)?.icon ?? null;
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.layoutService.hide();
|
||||
}
|
||||
}
|
||||
162
web/src/app/lore/lore-sidebar.helper.ts
Normal file
162
web/src/app/lore/lore-sidebar.helper.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { forkJoin, Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { LoreService } from '../services/lore.service';
|
||||
import { TemplateService } from '../services/template.service';
|
||||
import { PageService } from '../services/page.service';
|
||||
import { Lore, LoreNode } from '../services/lore.model';
|
||||
import { Template } from '../services/template.model';
|
||||
import { Page } from '../services/page.model';
|
||||
import {
|
||||
SecondarySidebarConfig, TreeItem, GlobalItem, BottomPanel
|
||||
} from '../services/layout.service';
|
||||
|
||||
export interface LoreSidebarData {
|
||||
lore: Lore;
|
||||
allLores: Lore[];
|
||||
nodes: LoreNode[];
|
||||
templates: Template[];
|
||||
pages: Page[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge toutes les données nécessaires à la sidebar d'un Lore en parallèle.
|
||||
* Centralise le pattern commun aux écrans lore-detail, lore-node-create,
|
||||
* template-create, template-edit, page-create, page-edit.
|
||||
*/
|
||||
export function loadLoreSidebarData(
|
||||
loreId: string,
|
||||
loreService: LoreService,
|
||||
templateService: TemplateService,
|
||||
pageService: PageService
|
||||
): Observable<LoreSidebarData> {
|
||||
return forkJoin({
|
||||
lore: loreService.getLoreById(loreId),
|
||||
allLores: loreService.getAllLores(),
|
||||
nodes: loreService.getLoreNodes(loreId),
|
||||
templates: templateService.getByLoreId(loreId),
|
||||
pages: pageService.getByLoreId(loreId)
|
||||
}).pipe(
|
||||
map(data => data as LoreSidebarData)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit la config complète de la SecondarySidebar pour un Lore, incluant
|
||||
* le panneau "Templates" en bas.
|
||||
*/
|
||||
export function buildLoreSidebarConfig(data: LoreSidebarData): SecondarySidebarConfig {
|
||||
const { lore, allLores, nodes, templates, pages } = data;
|
||||
|
||||
// Regroupe les pages par nodeId et les sous-dossiers par parentId pour un accès O(1).
|
||||
const pagesByNode = new Map<string, Page[]>();
|
||||
for (const p of pages) {
|
||||
const bucket = pagesByNode.get(p.nodeId) ?? [];
|
||||
bucket.push(p);
|
||||
pagesByNode.set(p.nodeId, bucket);
|
||||
}
|
||||
const childrenByParent = new Map<string, LoreNode[]>();
|
||||
for (const n of nodes) {
|
||||
const parentKey = n.parentId ?? '__root__';
|
||||
const bucket = childrenByParent.get(parentKey) ?? [];
|
||||
bucket.push(n);
|
||||
childrenByParent.set(parentKey, bucket);
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit récursivement le TreeItem d'un dossier :
|
||||
* ses sous-dossiers, puis ses pages, puis les actions "+ Nouveau dossier" et "+ Nouvelle page".
|
||||
*/
|
||||
const buildFolderItem = (node: LoreNode): TreeItem => {
|
||||
const subFolders = childrenByParent.get(node.id!) ?? [];
|
||||
const nodePages = pagesByNode.get(node.id!) ?? [];
|
||||
const children: TreeItem[] = [
|
||||
...subFolders.map(buildFolderItem),
|
||||
...nodePages.map(p => ({
|
||||
id: p.id!,
|
||||
label: p.title,
|
||||
route: `/lore/${lore.id}/pages/${p.id}`
|
||||
})),
|
||||
{
|
||||
id: `create-folder-${node.id}`,
|
||||
label: '+ Nouveau dossier',
|
||||
isAction: true,
|
||||
route: `/lore/${lore.id}/folders/${node.id}/create`
|
||||
},
|
||||
{
|
||||
id: `create-page-${node.id}`,
|
||||
label: '+ Nouvelle page',
|
||||
isAction: true,
|
||||
route: `/lore/${lore.id}/nodes/${node.id}/pages/create`
|
||||
}
|
||||
];
|
||||
return {
|
||||
id: node.id!,
|
||||
label: node.name,
|
||||
iconKey: node.icon ?? undefined,
|
||||
route: `/lore/${lore.id}/folders/${node.id}/edit`,
|
||||
meta: nodePages.length > 0 ? String(nodePages.length) : undefined,
|
||||
children
|
||||
};
|
||||
};
|
||||
|
||||
// L'arbre démarre aux dossiers racine (parentId nul ou vide).
|
||||
const rootFolders = childrenByParent.get('__root__') ?? [];
|
||||
const treeItems: TreeItem[] = rootFolders.map(buildFolderItem);
|
||||
|
||||
const globalItems: GlobalItem[] = allLores.map(l => ({
|
||||
id: l.id!, name: l.name, route: `/lore/${l.id}`
|
||||
}));
|
||||
|
||||
const templatesPanel: BottomPanel = {
|
||||
id: 'templates',
|
||||
title: 'Templates',
|
||||
initiallyOpen: true,
|
||||
items: [
|
||||
...templates.map(t => ({
|
||||
id: t.id!,
|
||||
label: t.name,
|
||||
meta: `${t.fieldCount ?? t.fields.length} champs`,
|
||||
route: `/lore/${lore.id}/templates/${t.id}`
|
||||
})),
|
||||
{
|
||||
id: 'create-template',
|
||||
label: '+ Nouveau template',
|
||||
isAction: true,
|
||||
route: `/lore/${lore.id}/templates/create`
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
return {
|
||||
title: lore.name,
|
||||
items: treeItems,
|
||||
createActions: [
|
||||
{ id: 'create-node', label: '+ Dossier', variant: 'primary', route: `/lore/${lore.id}/nodes/create` },
|
||||
{ id: 'create-page', label: '+ Page', variant: 'secondary', route: `/lore/${lore.id}/pages/create` }
|
||||
],
|
||||
globalItems,
|
||||
globalBackLabel: 'Tous les lores',
|
||||
globalBackRoute: '/lore',
|
||||
bottomPanel: templatesPanel
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne l'ensemble des IDs de `rootId` et de tous ses descendants (sous-dossiers).
|
||||
* Utilisé pour empêcher de choisir comme parent un dossier qui serait soi-même
|
||||
* ou un de ses descendants (ce qui créerait un cycle dans l'arbre).
|
||||
*/
|
||||
export function collectDescendantIds(rootId: string, allNodes: LoreNode[]): Set<string> {
|
||||
const ids = new Set<string>([rootId]);
|
||||
let grew = true;
|
||||
while (grew) {
|
||||
grew = false;
|
||||
for (const n of allNodes) {
|
||||
if (n.parentId && ids.has(n.parentId) && !ids.has(n.id!)) {
|
||||
ids.add(n.id!);
|
||||
grew = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
42
web/src/app/lore/lore.component.html
Normal file
42
web/src/app/lore/lore.component.html
Normal file
@@ -0,0 +1,42 @@
|
||||
<div class="lore-page">
|
||||
|
||||
<div class="lore-hero">
|
||||
<lucide-icon [img]="BookOpen" [size]="56" class="hero-icon"></lucide-icon>
|
||||
<h1>Vos Univers</h1>
|
||||
<p class="hero-subtitle">Sélectionnez un lore existant ou créez un nouvel univers</p>
|
||||
</div>
|
||||
|
||||
<div class="lore-grid">
|
||||
|
||||
<div class="lore-card" *ngFor="let lore of lores" (click)="navigateToDetail(lore.id!)">
|
||||
<div class="card-header">
|
||||
<lucide-icon [img]="Folder" [size]="20" class="card-icon"></lucide-icon>
|
||||
<span class="card-date">Il y a 2h</span>
|
||||
</div>
|
||||
<h2>{{ lore.name }}</h2>
|
||||
<p class="card-description">{{ lore.description }}</p>
|
||||
<div class="card-stats">
|
||||
<span>📄 {{ lore.pageCount || 0 }} pages</span>
|
||||
<span>🌳 {{ lore.nodeCount || 0 }} dossiers</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lore-card card-new" (click)="openCreateModal()">
|
||||
<div class="new-icon">
|
||||
<lucide-icon [img]="Plus" [size]="20"></lucide-icon>
|
||||
</div>
|
||||
<h2>Nouveau Lore</h2>
|
||||
<p class="card-description">Créez un nouvel univers</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<p class="tip">💡 Astuce : Utilisez les templates pour structurer votre univers de manière cohérente</p>
|
||||
|
||||
</div>
|
||||
|
||||
<app-lore-create
|
||||
*ngIf="showCreateModal"
|
||||
(close)="onModalClose()"
|
||||
(created)="onLoreCreated($event)">
|
||||
</app-lore-create>
|
||||
102
web/src/app/lore/lore.component.scss
Normal file
102
web/src/app/lore/lore.component.scss
Normal file
@@ -0,0 +1,102 @@
|
||||
.lore-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 3rem 2rem;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.lore-hero {
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
|
||||
.hero-icon { display: block; margin-bottom: 1rem; color: #6c63ff; }
|
||||
|
||||
h1 { font-size: 2rem; font-weight: 700; color: white; margin-bottom: 0.5rem; }
|
||||
|
||||
.hero-subtitle { color: #6b7280; font-size: 0.95rem; }
|
||||
}
|
||||
|
||||
.lore-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1.5rem;
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
.lore-card {
|
||||
background: #111827;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, transform 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: #6c63ff;
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.card-icon { font-size: 1.25rem; color: #6c63ff; }
|
||||
.card-date { font-size: 0.75rem; color: #6b7280; }
|
||||
|
||||
h2 { color: white; font-size: 1.1rem; margin-bottom: 0.5rem; }
|
||||
|
||||
.card-description {
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 1rem;
|
||||
// Tronque à 3 lignes avec "…" : évite qu'une description longue étire
|
||||
// la carte et par ricochet la carte "Nouveau Lore" alignée sur sa hauteur.
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.card-stats {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.8rem;
|
||||
color: #4b5563;
|
||||
}
|
||||
}
|
||||
|
||||
.card-new {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-style: dashed;
|
||||
border-color: #374151;
|
||||
text-align: center;
|
||||
|
||||
.new-icon {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 50%;
|
||||
background: #1f2937;
|
||||
color: #6c63ff;
|
||||
font-size: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.tip {
|
||||
margin-top: 3rem;
|
||||
font-size: 0.8rem;
|
||||
color: #4b5563;
|
||||
}
|
||||
72
web/src/app/lore/lore.component.ts
Normal file
72
web/src/app/lore/lore.component.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
import { LucideAngularModule, BookOpen, Folder, Plus } from 'lucide-angular';
|
||||
import { LoreService } from '../services/lore.service';
|
||||
import { Lore } from '../services/lore.model';
|
||||
import { LoreCreateComponent } from './lore-create/lore-create.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-lore',
|
||||
standalone: true,
|
||||
imports: [CommonModule, LucideAngularModule, LoreCreateComponent],
|
||||
templateUrl: './lore.component.html',
|
||||
styleUrls: ['./lore.component.scss']
|
||||
})
|
||||
export class LoreComponent implements OnInit {
|
||||
lores: Lore[] = [];
|
||||
loading = true;
|
||||
error = false;
|
||||
|
||||
readonly BookOpen = BookOpen;
|
||||
readonly Folder = Folder;
|
||||
readonly Plus = Plus;
|
||||
|
||||
showCreateModal = false;
|
||||
|
||||
constructor(
|
||||
private loreService: LoreService,
|
||||
private router: Router
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadLores();
|
||||
}
|
||||
|
||||
loadLores(): void {
|
||||
this.loreService.getAllLores().subscribe({
|
||||
next: (data) => {
|
||||
this.lores = data;
|
||||
this.loading = false;
|
||||
},
|
||||
error: () => {
|
||||
this.error = true;
|
||||
this.loading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openCreateModal(): void {
|
||||
this.showCreateModal = true;
|
||||
}
|
||||
|
||||
onModalClose(): void {
|
||||
this.showCreateModal = false;
|
||||
}
|
||||
|
||||
onLoreCreated(data: { name: string; description: string }): void {
|
||||
this.loreService.createLore(data).subscribe({
|
||||
next: () => {
|
||||
this.showCreateModal = false;
|
||||
this.loadLores();
|
||||
},
|
||||
error: () => {
|
||||
console.error('Erreur lors de la création du Lore');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
navigateToDetail(id: string): void {
|
||||
this.router.navigate(['/lore', id]);
|
||||
}
|
||||
}
|
||||
66
web/src/app/lore/page-create/page-create.component.html
Normal file
66
web/src/app/lore/page-create/page-create.component.html
Normal file
@@ -0,0 +1,66 @@
|
||||
<div class="page">
|
||||
|
||||
<header class="page-header">
|
||||
<h1>Créer une nouvelle Page</h1>
|
||||
<p class="subtitle">Créez une page à partir d'un template existant</p>
|
||||
</header>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="submit()" class="page-form">
|
||||
|
||||
<!-- Titre -->
|
||||
<div class="field">
|
||||
<label>Titre de la page *</label>
|
||||
<input type="text" formControlName="title" placeholder="Ex: Maître Eldrin, La Cité d'Argent..." />
|
||||
</div>
|
||||
|
||||
<!-- Template -->
|
||||
<div class="field">
|
||||
<label>Template *</label>
|
||||
|
||||
<div class="templates-grid" *ngIf="templates.length; else emptyTemplates">
|
||||
<button
|
||||
type="button"
|
||||
class="template-card"
|
||||
*ngFor="let t of templates"
|
||||
[class.selected]="selectedTemplateId === t.id"
|
||||
(click)="selectTemplate(t)">
|
||||
<div class="template-card-head">
|
||||
<lucide-icon [img]="FileText" [size]="16"></lucide-icon>
|
||||
<span class="template-name">{{ t.name }}</span>
|
||||
</div>
|
||||
<p class="template-description">{{ t.description || '—' }}</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ng-template #emptyTemplates>
|
||||
<p class="empty-hint">
|
||||
Aucun template défini pour ce Lore.
|
||||
<a [routerLink]="['/lore', loreId, 'templates', 'create']">Créer un template</a> d'abord.
|
||||
</p>
|
||||
</ng-template>
|
||||
</div>
|
||||
|
||||
<!-- Dossier de destination -->
|
||||
<div class="field">
|
||||
<label>Dossier de destination *</label>
|
||||
<select formControlName="nodeId" [attr.disabled]="preselectedNodeId ? true : null">
|
||||
<option value="" disabled>Sélectionnez un dossier</option>
|
||||
<option *ngFor="let node of nodes" [value]="node.id">{{ node.name }}</option>
|
||||
</select>
|
||||
<p class="hint">La page sera créée dans ce dossier</p>
|
||||
</div>
|
||||
|
||||
<!-- Aide contextuelle -->
|
||||
<div class="info-box">
|
||||
💡 Une fois créée, vous pourrez remplir les champs du template et utiliser l'Assistant IA pour développer le contenu.
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="actions-row">
|
||||
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
|
||||
<button type="submit" class="btn-primary" [disabled]="!canSubmit">Créer la page</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
159
web/src/app/lore/page-create/page-create.component.scss
Normal file
159
web/src/app/lore/page-create/page-create.component.scss
Normal file
@@ -0,0 +1,159 @@
|
||||
.page {
|
||||
padding: 2rem 3rem;
|
||||
max-width: 860px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 2rem;
|
||||
|
||||
h1 {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
.subtitle {
|
||||
color: #9ca3af;
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.page-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
input, select {
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
color: white;
|
||||
padding: 0.75rem 0.9rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #6c63ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.76rem;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
color: #9ca3af;
|
||||
font-size: 0.88rem;
|
||||
|
||||
a { color: #a5b4fc; text-decoration: underline; }
|
||||
}
|
||||
|
||||
.templates-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.template-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
padding: 0.9rem 1rem;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
color: #d1d5db;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
|
||||
&:hover { border-color: #3a3a55; background: #20203a; }
|
||||
|
||||
&.selected {
|
||||
border-color: #6c63ff;
|
||||
background: #1e1c3a;
|
||||
}
|
||||
|
||||
.template-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
.template-name {
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
|
||||
.template-description {
|
||||
margin: 0;
|
||||
font-size: 0.78rem;
|
||||
color: #9ca3af;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
border-left: 3px solid #6c63ff;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
color: #d1d5db;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.actions-row {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-primary, .btn-secondary {
|
||||
padding: 0.65rem 1.2rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #6c63ff;
|
||||
color: white;
|
||||
|
||||
&:hover:not(:disabled) { background: #5a52e0; }
|
||||
&:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #2a2a3d;
|
||||
color: #d1d5db;
|
||||
|
||||
&:hover { background: #363650; }
|
||||
}
|
||||
112
web/src/app/lore/page-create/page-create.component.ts
Normal file
112
web/src/app/lore/page-create/page-create.component.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { LucideAngularModule, FileText } from 'lucide-angular';
|
||||
import { LoreService } from '../../services/lore.service';
|
||||
import { TemplateService } from '../../services/template.service';
|
||||
import { PageService } from '../../services/page.service';
|
||||
import { LayoutService } from '../../services/layout.service';
|
||||
import { PageTitleService } from '../../services/page-title.service';
|
||||
import { LoreNode } from '../../services/lore.model';
|
||||
import { Template } from '../../services/template.model';
|
||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||
|
||||
/**
|
||||
* Écran de création d'une Page.
|
||||
*
|
||||
* Deux entrées possibles :
|
||||
* - /lore/:loreId/pages/create → noeud choisi depuis le template
|
||||
* - /lore/:loreId/nodes/:nodeId/pages/create → noeud pré-rempli depuis l'URL
|
||||
*
|
||||
* Le MVP est volontairement simple (maquette "création de page") : titre +
|
||||
* choix de template (grille) + noeud de destination. L'édition détaillée des
|
||||
* champs dynamiques du template se fait APRÈS création, via l'écran page-edit.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-page-create',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule, RouterModule, LucideAngularModule],
|
||||
templateUrl: './page-create.component.html',
|
||||
styleUrls: ['./page-create.component.scss']
|
||||
})
|
||||
export class PageCreateComponent implements OnInit, OnDestroy {
|
||||
readonly FileText = FileText;
|
||||
|
||||
form: FormGroup;
|
||||
loreId = '';
|
||||
/** Pré-rempli si la route contient :nodeId. */
|
||||
preselectedNodeId: string | null = null;
|
||||
nodes: LoreNode[] = [];
|
||||
templates: Template[] = [];
|
||||
/** Template actuellement sélectionné dans la grille. */
|
||||
selectedTemplateId: string | null = null;
|
||||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private loreService: LoreService,
|
||||
private templateService: TemplateService,
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService
|
||||
) {
|
||||
this.form = this.fb.group({
|
||||
title: ['', Validators.required],
|
||||
nodeId: ['', Validators.required]
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.pageTitleService.set('Nouvelle page');
|
||||
this.loreId = this.route.snapshot.paramMap.get('loreId')!;
|
||||
this.preselectedNodeId = this.route.snapshot.paramMap.get('nodeId');
|
||||
|
||||
loadLoreSidebarData(this.loreId, this.loreService, this.templateService, this.pageService)
|
||||
.subscribe(data => {
|
||||
this.nodes = data.nodes;
|
||||
this.templates = data.templates;
|
||||
this.layoutService.show(buildLoreSidebarConfig(data));
|
||||
|
||||
// Si nodeId fourni par l'URL, on verrouille la valeur du formulaire.
|
||||
if (this.preselectedNodeId) {
|
||||
this.form.patchValue({ nodeId: this.preselectedNodeId });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
selectTemplate(template: Template): void {
|
||||
this.selectedTemplateId = template.id!;
|
||||
// Si pas de noeud pré-choisi par l'URL, on pré-remplit avec le defaultNodeId du template.
|
||||
if (!this.preselectedNodeId && template.defaultNodeId) {
|
||||
this.form.patchValue({ nodeId: template.defaultNodeId });
|
||||
}
|
||||
}
|
||||
|
||||
get canSubmit(): boolean {
|
||||
return this.form.valid && !!this.selectedTemplateId;
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (!this.canSubmit) return;
|
||||
const raw = this.form.value;
|
||||
this.pageService.create({
|
||||
loreId: this.loreId,
|
||||
nodeId: raw.nodeId,
|
||||
templateId: this.selectedTemplateId!,
|
||||
title: raw.title
|
||||
}).subscribe({
|
||||
next: created => this.router.navigate(['/lore', this.loreId, 'pages', created.id]),
|
||||
error: () => console.error('Erreur lors de la création de la page')
|
||||
});
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.router.navigate(['/lore', this.loreId]);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.layoutService.hide();
|
||||
}
|
||||
}
|
||||
90
web/src/app/lore/page-edit/page-edit.component.html
Normal file
90
web/src/app/lore/page-edit/page-edit.component.html
Normal file
@@ -0,0 +1,90 @@
|
||||
<div class="page" *ngIf="page">
|
||||
|
||||
<app-breadcrumb [items]="breadcrumbItems"></app-breadcrumb>
|
||||
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>{{ page.title }}</h1>
|
||||
<p class="subtitle">{{ template?.name || 'Page' }}</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="btn-ai" disabled title="Bientôt disponible">
|
||||
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
|
||||
Assistant IA
|
||||
</button>
|
||||
<button type="button" class="btn-danger" (click)="delete()">Supprimer</button>
|
||||
<button type="button" class="btn-primary" (click)="save()" [disabled]="!title.trim()">
|
||||
Sauvegarder
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form class="edit-form">
|
||||
|
||||
<!-- Identité ----------------------------------------------------- -->
|
||||
<div class="field">
|
||||
<label>Nom</label>
|
||||
<input type="text" [(ngModel)]="title" name="title" required />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Dossier</label>
|
||||
<select [(ngModel)]="nodeId" name="nodeId">
|
||||
<option *ngFor="let node of nodes" [value]="node.id">{{ node.name }}</option>
|
||||
</select>
|
||||
<p class="hint">Déplacez cette page dans un autre dossier</p>
|
||||
</div>
|
||||
|
||||
<!-- Champs dynamiques du template -------------------------------- -->
|
||||
<ng-container *ngIf="template?.fields?.length">
|
||||
<h2 class="section-title">Champs</h2>
|
||||
<div class="field" *ngFor="let fieldName of template!.fields">
|
||||
<label>{{ fieldName }}</label>
|
||||
<textarea
|
||||
[(ngModel)]="values[fieldName]"
|
||||
[name]="'value_' + fieldName"
|
||||
rows="4"
|
||||
[placeholder]="'Valeur pour ' + fieldName + '...'">
|
||||
</textarea>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<!-- Tags --------------------------------------------------------- -->
|
||||
<h2 class="section-title">Tags</h2>
|
||||
<div class="field">
|
||||
<app-chips-input
|
||||
[value]="tags"
|
||||
(valueChange)="tags = $event"
|
||||
placeholder="Ajouter un tag (Entrée pour valider)...">
|
||||
</app-chips-input>
|
||||
<p class="hint">Mots-clés libres pour classer et retrouver cette page</p>
|
||||
</div>
|
||||
|
||||
<!-- Liens vers d'autres pages ----------------------------------- -->
|
||||
<h2 class="section-title">Pages liées</h2>
|
||||
<div class="field">
|
||||
<app-lore-link-picker
|
||||
[value]="relatedPageIds"
|
||||
[availablePages]="allPages"
|
||||
[excludePageId]="pageId"
|
||||
[loreId]="loreId"
|
||||
(valueChange)="relatedPageIds = $event">
|
||||
</app-lore-link-picker>
|
||||
<p class="hint">Cliquez sur un lien pour ouvrir la page associée</p>
|
||||
</div>
|
||||
|
||||
<!-- Notes privées ----------------------------------------------- -->
|
||||
<h2 class="section-title">Notes privées</h2>
|
||||
<div class="field">
|
||||
<textarea
|
||||
[(ngModel)]="notes"
|
||||
name="notes"
|
||||
rows="4"
|
||||
placeholder="Notes personnelles (non exportées vers FoundryVTT)">
|
||||
</textarea>
|
||||
<p class="hint">Visibles uniquement par vous. Utiles pour préparer vos sessions.</p>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
122
web/src/app/lore/page-edit/page-edit.component.scss
Normal file
122
web/src/app/lore/page-edit/page-edit.component.scss
Normal file
@@ -0,0 +1,122 @@
|
||||
.page {
|
||||
padding: 2rem 3rem;
|
||||
max-width: 1000px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 2rem;
|
||||
|
||||
h1 {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
.subtitle {
|
||||
color: #9ca3af;
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
margin: 1.5rem 0 0.25rem;
|
||||
border-top: 1px solid #1e1e3a;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.edit-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
|
||||
label {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
input, textarea, select {
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
color: white;
|
||||
padding: 0.7rem 0.9rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #6c63ff;
|
||||
}
|
||||
}
|
||||
|
||||
textarea { resize: vertical; }
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.76rem;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btn-primary, .btn-secondary, .btn-danger, .btn-ai {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.6rem 1.1rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #6c63ff;
|
||||
color: white;
|
||||
|
||||
&:hover:not(:disabled) { background: #5a52e0; }
|
||||
&:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #2a2a3d;
|
||||
color: #d1d5db;
|
||||
|
||||
&:hover { background: #363650; }
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #3f1f1f;
|
||||
color: #fca5a5;
|
||||
|
||||
&:hover { background: #5a2a2a; }
|
||||
}
|
||||
|
||||
.btn-ai {
|
||||
background: transparent;
|
||||
color: #a5b4fc;
|
||||
border: 1px solid #2a2a3d;
|
||||
|
||||
&:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
&:hover:not(:disabled) { background: #1f2937; }
|
||||
}
|
||||
184
web/src/app/lore/page-edit/page-edit.component.ts
Normal file
184
web/src/app/lore/page-edit/page-edit.component.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { forkJoin } from 'rxjs';
|
||||
import { LucideAngularModule, Sparkles } from 'lucide-angular';
|
||||
import { LoreService } from '../../services/lore.service';
|
||||
import { TemplateService } from '../../services/template.service';
|
||||
import { PageService } from '../../services/page.service';
|
||||
import { LayoutService } from '../../services/layout.service';
|
||||
import { PageTitleService } from '../../services/page-title.service';
|
||||
import { LoreNode } from '../../services/lore.model';
|
||||
import { Template } from '../../services/template.model';
|
||||
import { Page } from '../../services/page.model';
|
||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||
import { ChipsInputComponent } from '../../shared/chips-input/chips-input.component';
|
||||
import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link-picker.component';
|
||||
import { BreadcrumbComponent, BreadcrumbItem } from '../../shared/breadcrumb/breadcrumb.component';
|
||||
import { Lore } from '../../services/lore.model';
|
||||
|
||||
/**
|
||||
* Écran d'édition d'une Page.
|
||||
*
|
||||
* Fonctionnalités actuelles (Phase 5A + 5B) :
|
||||
* - Titre (modifiable) + Dossier (déplaçable)
|
||||
* - Champs dynamiques du Template (un textarea par champ, valeurs stockées dans `values`)
|
||||
* - Tags (chips) — Phase 5B
|
||||
* - Liens vers d'autres pages (autocomplete) — Phase 5B
|
||||
* - Notes privées MJ
|
||||
*
|
||||
* À venir (Phase 5D) :
|
||||
* - Bouton "Assistant IA" branché (Phase 3 Python)
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-page-edit',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, LucideAngularModule, ChipsInputComponent, LoreLinkPickerComponent, BreadcrumbComponent],
|
||||
templateUrl: './page-edit.component.html',
|
||||
styleUrls: ['./page-edit.component.scss']
|
||||
})
|
||||
export class PageEditComponent implements OnInit, OnDestroy {
|
||||
readonly Sparkles = Sparkles;
|
||||
|
||||
loreId = '';
|
||||
pageId = '';
|
||||
lore: Lore | null = null;
|
||||
page: Page | null = null;
|
||||
template: Template | null = null;
|
||||
nodes: LoreNode[] = [];
|
||||
/** Toutes les pages du lore — nécessaire au lore-link-picker pour l'autocomplete. */
|
||||
allPages: Page[] = [];
|
||||
|
||||
/** Modèle du formulaire (bindé via ngModel). */
|
||||
title = '';
|
||||
nodeId = '';
|
||||
notes = '';
|
||||
/** Valeurs des champs dynamiques, indexées par fieldName. */
|
||||
values: Record<string, string> = {};
|
||||
/** Étiquettes libres (Phase 5B). */
|
||||
tags: string[] = [];
|
||||
/** IDs des pages liées (Phase 5B). */
|
||||
relatedPageIds: string[] = [];
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private loreService: LoreService,
|
||||
private templateService: TemplateService,
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loreId = this.route.snapshot.paramMap.get('loreId')!;
|
||||
|
||||
// S'abonner à paramMap plutôt que de lire snapshot une fois : sinon, quand on
|
||||
// navigue d'une page à une autre (ex. via les chips du lore-link-picker),
|
||||
// Angular réutilise le composant et ngOnInit ne se relance pas → l'écran
|
||||
// resterait figé sur l'ancienne page.
|
||||
this.route.paramMap.subscribe(pm => {
|
||||
const newPageId = pm.get('pageId')!;
|
||||
if (newPageId && newPageId !== this.pageId) {
|
||||
this.pageId = newPageId;
|
||||
this.load();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private load(): void {
|
||||
forkJoin({
|
||||
sidebar: loadLoreSidebarData(this.loreId, this.loreService, this.templateService, this.pageService),
|
||||
page: this.pageService.getById(this.pageId)
|
||||
}).subscribe(({ sidebar, page }) => {
|
||||
this.lore = sidebar.lore;
|
||||
this.nodes = sidebar.nodes;
|
||||
this.allPages = sidebar.pages;
|
||||
this.layoutService.show(buildLoreSidebarConfig(sidebar));
|
||||
this.hydrate(page, sidebar.templates);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit le fil d'Ariane : Lore > [dossiers parents...] > Dossier courant > Page.
|
||||
* Les items sont cliquables sauf le dernier (position courante).
|
||||
* On remonte la hiérarchie via `parentId` jusqu'à la racine, puis on inverse.
|
||||
*/
|
||||
get breadcrumbItems(): BreadcrumbItem[] {
|
||||
if (!this.lore || !this.page) return [];
|
||||
|
||||
const items: BreadcrumbItem[] = [
|
||||
{ label: this.lore.name, route: ['/lore', this.loreId] }
|
||||
];
|
||||
|
||||
// Chemin des dossiers (racine → dossier courant) via remontée parentId.
|
||||
const folderChain: LoreNode[] = [];
|
||||
let currentNode = this.nodes.find(n => n.id === this.nodeId);
|
||||
while (currentNode) {
|
||||
folderChain.unshift(currentNode);
|
||||
currentNode = currentNode.parentId
|
||||
? this.nodes.find(n => n.id === currentNode!.parentId)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
for (const node of folderChain) {
|
||||
items.push({
|
||||
label: node.name,
|
||||
route: ['/lore', this.loreId, 'folders', node.id, 'edit']
|
||||
});
|
||||
}
|
||||
|
||||
// Position courante : la page (non-cliquable).
|
||||
items.push({ label: this.title || this.page.title });
|
||||
return items;
|
||||
}
|
||||
|
||||
private hydrate(page: Page, templates: Template[]): void {
|
||||
this.page = page;
|
||||
this.template = templates.find(t => t.id === page.templateId) ?? null;
|
||||
this.title = page.title;
|
||||
this.nodeId = page.nodeId;
|
||||
this.notes = page.notes ?? '';
|
||||
// On initialise une entrée pour chaque field du template, même vide,
|
||||
// pour que le formulaire ait toujours les champs attendus.
|
||||
const base: Record<string, string> = {};
|
||||
for (const f of this.template?.fields ?? []) {
|
||||
base[f] = page.values?.[f] ?? '';
|
||||
}
|
||||
this.values = base;
|
||||
this.tags = [...(page.tags ?? [])];
|
||||
this.relatedPageIds = [...(page.relatedPageIds ?? [])];
|
||||
this.pageTitleService.set(page.title);
|
||||
}
|
||||
|
||||
save(): void {
|
||||
if (!this.page || !this.title.trim()) return;
|
||||
const updated: Page = {
|
||||
...this.page,
|
||||
title: this.title,
|
||||
nodeId: this.nodeId,
|
||||
notes: this.notes,
|
||||
values: this.values,
|
||||
tags: this.tags,
|
||||
relatedPageIds: this.relatedPageIds
|
||||
};
|
||||
this.pageService.update(this.pageId, updated).subscribe({
|
||||
next: () => this.router.navigate(['/lore', this.loreId]),
|
||||
error: () => console.error('Erreur lors de la sauvegarde de la page')
|
||||
});
|
||||
}
|
||||
|
||||
delete(): void {
|
||||
if (!this.page) return;
|
||||
if (!confirm(`Supprimer la page "${this.page.title}" ?`)) return;
|
||||
this.pageService.delete(this.pageId).subscribe({
|
||||
next: () => this.router.navigate(['/lore', this.loreId]),
|
||||
error: () => console.error('Erreur lors de la suppression de la page')
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.layoutService.hide();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<div class="page">
|
||||
|
||||
<header class="page-header">
|
||||
<h1>Créer un nouveau Template</h1>
|
||||
<p class="subtitle">Définissez un gabarit personnalisé pour créer des pages cohérentes</p>
|
||||
</header>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="submit()" class="template-form">
|
||||
|
||||
<!-- Colonne gauche ---------------------------------------------- -->
|
||||
<div class="col-left">
|
||||
|
||||
<div class="field">
|
||||
<label>Nom du template *</label>
|
||||
<input type="text" formControlName="name" placeholder="Ex: Auberge, Artefact, Monstre..." />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Description</label>
|
||||
<textarea formControlName="description" rows="4" placeholder="À quoi sert ce template ?"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Dossier par défaut *</label>
|
||||
<select formControlName="defaultNodeId">
|
||||
<option value="" disabled>Sélectionnez un dossier</option>
|
||||
<option *ngFor="let node of nodes" [value]="node.id">{{ node.name }}</option>
|
||||
</select>
|
||||
<p class="hint">Les pages créées avec ce template seront placées dans ce dossier</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Colonne droite --------------------------------------------- -->
|
||||
<div class="col-right">
|
||||
|
||||
<label class="section-label">Champs du template *</label>
|
||||
|
||||
<ul class="fields-list">
|
||||
<li class="field-row" *ngFor="let f of fields; let i = index">
|
||||
<span class="field-chip">{{ f }}</span>
|
||||
<button type="button" class="btn-icon" (click)="removeField(i)" aria-label="Supprimer">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="field-row add-row">
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="newFieldName"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
placeholder="Nom du champ..."
|
||||
(keydown.enter)="$event.preventDefault(); addField()" />
|
||||
<button type="button" class="btn-add" (click)="addField()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="hint">Ajoutez les champs qui apparaîtront dans chaque page</p>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Actions ---------------------------------------------------- -->
|
||||
<div class="actions-row">
|
||||
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
|
||||
<button type="submit" class="btn-primary" [disabled]="form.invalid">Créer le template</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
180
web/src/app/lore/template-create/template-create.component.scss
Normal file
180
web/src/app/lore/template-create/template-create.component.scss
Normal file
@@ -0,0 +1,180 @@
|
||||
.page {
|
||||
padding: 2rem 3rem;
|
||||
max-width: 1000px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 2rem;
|
||||
|
||||
h1 {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
.subtitle {
|
||||
color: #9ca3af;
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.template-form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2rem 2.5rem;
|
||||
|
||||
.col-left, .col-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.actions-row {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
|
||||
label {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
input, textarea, select {
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
color: white;
|
||||
padding: 0.7rem 0.9rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #6c63ff;
|
||||
}
|
||||
}
|
||||
|
||||
textarea { resize: vertical; }
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.76rem;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
color: #d1d5db;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.fields-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
.field-chip {
|
||||
flex: 1;
|
||||
background: #2a5f3f;
|
||||
color: #d1fae5;
|
||||
padding: 0.6rem 0.9rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
color: white;
|
||||
padding: 0.6rem 0.9rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.88rem;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #6c63ff;
|
||||
}
|
||||
}
|
||||
|
||||
&.add-row { margin-top: 0.5rem; }
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: #3f1f1f;
|
||||
color: #fca5a5;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover { background: #5a2a2a; }
|
||||
}
|
||||
|
||||
.btn-add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: #6c63ff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover { background: #5a52e0; }
|
||||
}
|
||||
|
||||
.btn-primary, .btn-secondary {
|
||||
padding: 0.65rem 1.2rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #6c63ff;
|
||||
color: white;
|
||||
|
||||
&:hover:not(:disabled) { background: #5a52e0; }
|
||||
&:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #2a2a3d;
|
||||
color: #d1d5db;
|
||||
|
||||
&:hover { background: #363650; }
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { LucideAngularModule, Plus, Trash2 } from 'lucide-angular';
|
||||
import { LoreService } from '../../services/lore.service';
|
||||
import { TemplateService } from '../../services/template.service';
|
||||
import { PageService } from '../../services/page.service';
|
||||
import { LayoutService } from '../../services/layout.service';
|
||||
import { LoreNode } from '../../services/lore.model';
|
||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||
|
||||
/**
|
||||
* Écran de création d'un Template (gabarit de Page).
|
||||
* - Champs principaux : nom, description, noeud par défaut.
|
||||
* - Liste dynamique de "champs du template" (ex: "Nom", "Description", "Personnalité").
|
||||
* Le user peut ajouter/retirer n'importe lequel — tous sont égaux.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-template-create',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, ReactiveFormsModule, LucideAngularModule],
|
||||
templateUrl: './template-create.component.html',
|
||||
styleUrls: ['./template-create.component.scss']
|
||||
})
|
||||
export class TemplateCreateComponent implements OnInit, OnDestroy {
|
||||
readonly Plus = Plus;
|
||||
readonly Trash2 = Trash2;
|
||||
|
||||
form: FormGroup;
|
||||
loreId = '';
|
||||
nodes: LoreNode[] = [];
|
||||
/** Champs dynamiques actuellement définis. */
|
||||
fields: string[] = ['Nom', 'Description'];
|
||||
/** Valeur courante de l'input d'ajout de champ (non binding direct pour reset facile). */
|
||||
newFieldName = '';
|
||||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private loreService: LoreService,
|
||||
private templateService: TemplateService,
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService
|
||||
) {
|
||||
this.form = this.fb.group({
|
||||
name: ['', Validators.required],
|
||||
description: [''],
|
||||
defaultNodeId: ['', Validators.required]
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loreId = this.route.snapshot.paramMap.get('loreId')!;
|
||||
loadLoreSidebarData(this.loreId, this.loreService, this.templateService, this.pageService).subscribe(data => {
|
||||
this.nodes = data.nodes;
|
||||
this.layoutService.show(buildLoreSidebarConfig(data));
|
||||
});
|
||||
}
|
||||
|
||||
addField(): void {
|
||||
const name = this.newFieldName.trim();
|
||||
if (!name || this.fields.includes(name)) return;
|
||||
this.fields = [...this.fields, name];
|
||||
this.newFieldName = '';
|
||||
}
|
||||
|
||||
removeField(index: number): void {
|
||||
this.fields = this.fields.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (this.form.invalid) return;
|
||||
const raw = this.form.value;
|
||||
this.templateService.create({
|
||||
loreId: this.loreId,
|
||||
name: raw.name,
|
||||
description: raw.description,
|
||||
defaultNodeId: raw.defaultNodeId,
|
||||
fields: this.fields
|
||||
}).subscribe({
|
||||
next: () => this.router.navigate(['/lore', this.loreId]),
|
||||
error: () => console.error('Erreur lors de la création du template')
|
||||
});
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.router.navigate(['/lore', this.loreId]);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.layoutService.hide();
|
||||
}
|
||||
}
|
||||
72
web/src/app/lore/template-edit/template-edit.component.html
Normal file
72
web/src/app/lore/template-edit/template-edit.component.html
Normal file
@@ -0,0 +1,72 @@
|
||||
<div class="page" *ngIf="template">
|
||||
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>{{ template.name }}</h1>
|
||||
<p class="subtitle">Template</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="btn-danger" (click)="delete()">Supprimer</button>
|
||||
<button type="button" class="btn-primary" (click)="save()" [disabled]="form.invalid">Sauvegarder</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form [formGroup]="form" class="template-form">
|
||||
|
||||
<!-- Colonne gauche ---------------------------------------------- -->
|
||||
<div class="col-left">
|
||||
|
||||
<div class="field">
|
||||
<label>Nom</label>
|
||||
<input type="text" formControlName="name" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Dossier par défaut</label>
|
||||
<select formControlName="defaultNodeId">
|
||||
<option value="">-- Aucun --</option>
|
||||
<option *ngFor="let node of nodes" [value]="node.id">{{ node.name }}</option>
|
||||
</select>
|
||||
<p class="hint">Les pages créées avec ce template seront placées dans ce dossier par défaut</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Description</label>
|
||||
<textarea formControlName="description" rows="6"></textarea>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Colonne droite --------------------------------------------- -->
|
||||
<div class="col-right">
|
||||
|
||||
<label class="section-label">Champs du template</label>
|
||||
|
||||
<ul class="fields-list">
|
||||
<li class="field-row" *ngFor="let f of fields; let i = index">
|
||||
<span class="field-chip">{{ f }}</span>
|
||||
<button type="button" class="btn-icon-ghost" (click)="removeField(i)" aria-label="Supprimer">
|
||||
<lucide-icon [img]="X" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="field-row add-row">
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="newFieldName"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
placeholder="+ Ajouter un champ"
|
||||
(keydown.enter)="$event.preventDefault(); addField()" />
|
||||
<button type="button" class="btn-add" (click)="addField()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="hint">Définissez les champs qui apparaîtront dans les pages créées avec ce template</p>
|
||||
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
205
web/src/app/lore/template-edit/template-edit.component.scss
Normal file
205
web/src/app/lore/template-edit/template-edit.component.scss
Normal file
@@ -0,0 +1,205 @@
|
||||
.page {
|
||||
padding: 2rem 3rem;
|
||||
max-width: 1000px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 2rem;
|
||||
|
||||
h1 {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
.subtitle {
|
||||
color: #9ca3af;
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
.template-form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2rem 2.5rem;
|
||||
|
||||
.col-left, .col-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
|
||||
label {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
input, textarea, select {
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
color: white;
|
||||
padding: 0.7rem 0.9rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #6c63ff;
|
||||
}
|
||||
}
|
||||
|
||||
textarea { resize: vertical; }
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.76rem;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
color: #d1d5db;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.fields-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
.field-chip {
|
||||
flex: 1;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
color: white;
|
||||
padding: 0.6rem 0.9rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.88rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
color: white;
|
||||
padding: 0.6rem 0.9rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.88rem;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #6c63ff;
|
||||
}
|
||||
|
||||
&::placeholder { color: #6b7280; }
|
||||
}
|
||||
|
||||
&.add-row {
|
||||
margin-top: 0.25rem;
|
||||
border: 1px dashed #2a2a3d;
|
||||
border-radius: 6px;
|
||||
padding: 0;
|
||||
|
||||
input {
|
||||
border: none;
|
||||
background: transparent;
|
||||
&:focus { border: none; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn-icon-ghost {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
|
||||
&:hover { color: #fca5a5; background: #2a1f1f; }
|
||||
}
|
||||
|
||||
.btn-add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin-right: 0.4rem;
|
||||
background: transparent;
|
||||
color: #6c63ff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover { background: #2a2a3d; }
|
||||
}
|
||||
|
||||
.btn-primary, .btn-secondary, .btn-danger {
|
||||
padding: 0.6rem 1.1rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #6c63ff;
|
||||
color: white;
|
||||
|
||||
&:hover:not(:disabled) { background: #5a52e0; }
|
||||
&:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #2a2a3d;
|
||||
color: #d1d5db;
|
||||
|
||||
&:hover { background: #363650; }
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #3f1f1f;
|
||||
color: #fca5a5;
|
||||
|
||||
&:hover { background: #5a2a2a; }
|
||||
}
|
||||
119
web/src/app/lore/template-edit/template-edit.component.ts
Normal file
119
web/src/app/lore/template-edit/template-edit.component.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { forkJoin } from 'rxjs';
|
||||
import { LucideAngularModule, Plus, X, Trash2 } from 'lucide-angular';
|
||||
import { LoreService } from '../../services/lore.service';
|
||||
import { TemplateService } from '../../services/template.service';
|
||||
import { PageService } from '../../services/page.service';
|
||||
import { LayoutService } from '../../services/layout.service';
|
||||
import { PageTitleService } from '../../services/page-title.service';
|
||||
import { LoreNode } from '../../services/lore.model';
|
||||
import { Template } from '../../services/template.model';
|
||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||
|
||||
/**
|
||||
* Écran d'édition d'un Template existant.
|
||||
* Mêmes champs que la création + bouton Supprimer.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-template-edit',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, ReactiveFormsModule, LucideAngularModule],
|
||||
templateUrl: './template-edit.component.html',
|
||||
styleUrls: ['./template-edit.component.scss']
|
||||
})
|
||||
export class TemplateEditComponent implements OnInit, OnDestroy {
|
||||
readonly Plus = Plus;
|
||||
readonly X = X;
|
||||
readonly Trash2 = Trash2;
|
||||
|
||||
form: FormGroup;
|
||||
loreId = '';
|
||||
templateId = '';
|
||||
template: Template | null = null;
|
||||
nodes: LoreNode[] = [];
|
||||
fields: string[] = [];
|
||||
newFieldName = '';
|
||||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private loreService: LoreService,
|
||||
private templateService: TemplateService,
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService
|
||||
) {
|
||||
this.form = this.fb.group({
|
||||
name: ['', Validators.required],
|
||||
description: [''],
|
||||
defaultNodeId: ['']
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loreId = this.route.snapshot.paramMap.get('loreId')!;
|
||||
this.templateId = this.route.snapshot.paramMap.get('templateId')!;
|
||||
|
||||
forkJoin({
|
||||
sidebar: loadLoreSidebarData(this.loreId, this.loreService, this.templateService, this.pageService),
|
||||
template: this.templateService.getById(this.templateId)
|
||||
}).subscribe(({ sidebar, template }) => {
|
||||
this.nodes = sidebar.nodes;
|
||||
this.layoutService.show(buildLoreSidebarConfig(sidebar));
|
||||
this.hydrate(template);
|
||||
});
|
||||
}
|
||||
|
||||
private hydrate(template: Template): void {
|
||||
this.template = template;
|
||||
this.fields = [...template.fields];
|
||||
this.form.patchValue({
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
defaultNodeId: template.defaultNodeId ?? ''
|
||||
});
|
||||
this.pageTitleService.set(template.name);
|
||||
}
|
||||
|
||||
addField(): void {
|
||||
const name = this.newFieldName.trim();
|
||||
if (!name || this.fields.includes(name)) return;
|
||||
this.fields = [...this.fields, name];
|
||||
this.newFieldName = '';
|
||||
}
|
||||
|
||||
removeField(index: number): void {
|
||||
this.fields = this.fields.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
save(): void {
|
||||
if (this.form.invalid || !this.template) return;
|
||||
const raw = this.form.value;
|
||||
this.templateService.update(this.templateId, {
|
||||
...this.template,
|
||||
name: raw.name,
|
||||
description: raw.description,
|
||||
defaultNodeId: raw.defaultNodeId || null,
|
||||
fields: this.fields
|
||||
}).subscribe({
|
||||
next: () => this.router.navigate(['/lore', this.loreId]),
|
||||
error: () => console.error('Erreur lors de la sauvegarde du template')
|
||||
});
|
||||
}
|
||||
|
||||
delete(): void {
|
||||
if (!confirm(`Supprimer le template "${this.template?.name}" ?`)) return;
|
||||
this.templateService.delete(this.templateId).subscribe({
|
||||
next: () => this.router.navigate(['/lore', this.loreId]),
|
||||
error: () => console.error('Erreur lors de la suppression du template')
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.layoutService.hide();
|
||||
}
|
||||
}
|
||||
120
web/src/app/services/campaign.model.ts
Normal file
120
web/src/app/services/campaign.model.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
// Interface TypeScript pour CampaignDTO (correspond au DTO Java)
|
||||
export interface Campaign {
|
||||
id?: string;
|
||||
name: string;
|
||||
description: string;
|
||||
playerCount?: number;
|
||||
arcCount?: number;
|
||||
chapterCount?: number;
|
||||
/** ID du Lore associé (weak reference cross-context). `null` = pas d'univers lié. */
|
||||
loreId?: string | null;
|
||||
}
|
||||
|
||||
// Interface pour la création de Campaign (sans id)
|
||||
export interface CampaignCreate {
|
||||
name: string;
|
||||
description: string;
|
||||
playerCount: number;
|
||||
loreId?: string | null;
|
||||
}
|
||||
|
||||
export interface Arc {
|
||||
id?: string;
|
||||
name: string;
|
||||
description?: string; // = Synopsis dans l'UI
|
||||
campaignId: string;
|
||||
order?: number;
|
||||
chapterCount?: number;
|
||||
|
||||
// Champs narratifs enrichis
|
||||
themes?: string;
|
||||
stakes?: string;
|
||||
gmNotes?: string;
|
||||
rewards?: string;
|
||||
resolution?: string;
|
||||
|
||||
/** IDs des pages du Lore liées à cet arc (weak cross-context refs). */
|
||||
relatedPageIds?: string[];
|
||||
}
|
||||
|
||||
// Payload pour la création d'un Arc (pas d'id)
|
||||
export interface ArcCreate {
|
||||
name: string;
|
||||
description?: string;
|
||||
campaignId: string;
|
||||
order: number;
|
||||
|
||||
themes?: string;
|
||||
stakes?: string;
|
||||
gmNotes?: string;
|
||||
rewards?: string;
|
||||
resolution?: string;
|
||||
|
||||
relatedPageIds?: string[];
|
||||
}
|
||||
|
||||
export interface Chapter {
|
||||
id?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
arcId: string;
|
||||
order?: number;
|
||||
|
||||
// Champs narratifs enrichis
|
||||
gmNotes?: string;
|
||||
playerObjectives?: string;
|
||||
narrativeStakes?: string;
|
||||
|
||||
relatedPageIds?: string[];
|
||||
}
|
||||
|
||||
export interface ChapterCreate {
|
||||
name: string;
|
||||
description?: string;
|
||||
arcId: string;
|
||||
order: number;
|
||||
|
||||
gmNotes?: string;
|
||||
playerObjectives?: string;
|
||||
narrativeStakes?: string;
|
||||
|
||||
relatedPageIds?: string[];
|
||||
}
|
||||
|
||||
export interface Scene {
|
||||
id?: string;
|
||||
name: string;
|
||||
description?: string; // = Description courte dans l'UI
|
||||
chapterId: string;
|
||||
order?: number;
|
||||
|
||||
// Champs narratifs enrichis
|
||||
location?: string;
|
||||
timing?: string;
|
||||
atmosphere?: string;
|
||||
playerNarration?: string;
|
||||
gmSecretNotes?: string;
|
||||
choicesConsequences?: string;
|
||||
combatDifficulty?: string;
|
||||
enemies?: string;
|
||||
|
||||
relatedPageIds?: string[];
|
||||
}
|
||||
|
||||
export interface SceneCreate {
|
||||
name: string;
|
||||
description?: string;
|
||||
chapterId: string;
|
||||
order: number;
|
||||
|
||||
location?: string;
|
||||
timing?: string;
|
||||
atmosphere?: string;
|
||||
playerNarration?: string;
|
||||
gmSecretNotes?: string;
|
||||
choicesConsequences?: string;
|
||||
combatDifficulty?: string;
|
||||
enemies?: string;
|
||||
|
||||
relatedPageIds?: string[];
|
||||
}
|
||||
105
web/src/app/services/campaign.service.ts
Normal file
105
web/src/app/services/campaign.service.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Campaign, CampaignCreate, Arc, ArcCreate, Chapter, ChapterCreate, Scene, SceneCreate } from './campaign.model';
|
||||
|
||||
/**
|
||||
* Service HTTP pour la gestion des Campagnes.
|
||||
* Port de sortie vers le Backend Java (Architecture Hexagonale).
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class CampaignService {
|
||||
private apiUrl = 'http://localhost:8080/api/campaigns';
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
getAllCampaigns(): Observable<Campaign[]> {
|
||||
return this.http.get<Campaign[]>(this.apiUrl);
|
||||
}
|
||||
|
||||
getCampaignById(id: string): Observable<Campaign> {
|
||||
return this.http.get<Campaign>(`${this.apiUrl}/${id}`);
|
||||
}
|
||||
|
||||
createCampaign(campaign: CampaignCreate): Observable<Campaign> {
|
||||
return this.http.post<Campaign>(this.apiUrl, campaign);
|
||||
}
|
||||
|
||||
updateCampaign(id: string, campaign: CampaignCreate): Observable<Campaign> {
|
||||
return this.http.put<Campaign>(`${this.apiUrl}/${id}`, campaign);
|
||||
}
|
||||
|
||||
deleteCampaign(id: string): Observable<void> {
|
||||
return this.http.delete<void>(`${this.apiUrl}/${id}`);
|
||||
}
|
||||
|
||||
// ========== ARC ==========
|
||||
getArcs(campaignId: string): Observable<Arc[]> {
|
||||
return this.http.get<Arc[]>(`http://localhost:8080/api/arcs/campaign/${campaignId}`);
|
||||
}
|
||||
|
||||
getArcById(id: string): Observable<Arc> {
|
||||
return this.http.get<Arc>(`http://localhost:8080/api/arcs/${id}`);
|
||||
}
|
||||
|
||||
createArc(payload: ArcCreate): Observable<Arc> {
|
||||
return this.http.post<Arc>('http://localhost:8080/api/arcs', payload);
|
||||
}
|
||||
|
||||
updateArc(id: string, payload: ArcCreate): Observable<Arc> {
|
||||
return this.http.put<Arc>(`http://localhost:8080/api/arcs/${id}`, payload);
|
||||
}
|
||||
|
||||
deleteArc(id: string): Observable<void> {
|
||||
return this.http.delete<void>(`http://localhost:8080/api/arcs/${id}`);
|
||||
}
|
||||
|
||||
// ========== CHAPTER ==========
|
||||
getChapters(arcId: string): Observable<Chapter[]> {
|
||||
return this.http.get<Chapter[]>(`http://localhost:8080/api/chapters/arc/${arcId}`);
|
||||
}
|
||||
|
||||
getChapterById(id: string): Observable<Chapter> {
|
||||
return this.http.get<Chapter>(`http://localhost:8080/api/chapters/${id}`);
|
||||
}
|
||||
|
||||
createChapter(payload: ChapterCreate): Observable<Chapter> {
|
||||
return this.http.post<Chapter>('http://localhost:8080/api/chapters', payload);
|
||||
}
|
||||
|
||||
updateChapter(id: string, payload: ChapterCreate): Observable<Chapter> {
|
||||
return this.http.put<Chapter>(`http://localhost:8080/api/chapters/${id}`, payload);
|
||||
}
|
||||
|
||||
deleteChapter(id: string): Observable<void> {
|
||||
return this.http.delete<void>(`http://localhost:8080/api/chapters/${id}`);
|
||||
}
|
||||
|
||||
// ========== SCENE ==========
|
||||
getScenes(chapterId: string): Observable<Scene[]> {
|
||||
return this.http.get<Scene[]>(`http://localhost:8080/api/scenes/chapter/${chapterId}`);
|
||||
}
|
||||
|
||||
getSceneById(id: string): Observable<Scene> {
|
||||
return this.http.get<Scene>(`http://localhost:8080/api/scenes/${id}`);
|
||||
}
|
||||
|
||||
createScene(payload: SceneCreate): Observable<Scene> {
|
||||
return this.http.post<Scene>('http://localhost:8080/api/scenes', payload);
|
||||
}
|
||||
|
||||
updateScene(id: string, payload: SceneCreate): Observable<Scene> {
|
||||
return this.http.put<Scene>(`http://localhost:8080/api/scenes/${id}`, payload);
|
||||
}
|
||||
|
||||
deleteScene(id: string): Observable<void> {
|
||||
return this.http.delete<void>(`http://localhost:8080/api/scenes/${id}`);
|
||||
}
|
||||
|
||||
search(q: string): Observable<Campaign[]> {
|
||||
const params = new HttpParams().set('q', q);
|
||||
return this.http.get<Campaign[]>(`${this.apiUrl}/search`, { params });
|
||||
}
|
||||
}
|
||||
16
web/src/app/services/global-search.service.ts
Normal file
16
web/src/app/services/global-search.service.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
/**
|
||||
* État global de la command palette (modale de recherche).
|
||||
* Ouverte via bouton sidebar, raccourci Ctrl+K / Cmd+K, ou API programmatique.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class GlobalSearchService {
|
||||
private readonly _open$ = new BehaviorSubject<boolean>(false);
|
||||
readonly open$ = this._open$.asObservable();
|
||||
|
||||
open(): void { this._open$.next(true); }
|
||||
close(): void { this._open$.next(false); }
|
||||
toggle(): void { this._open$.next(!this._open$.value); }
|
||||
}
|
||||
101
web/src/app/services/layout.service.ts
Normal file
101
web/src/app/services/layout.service.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
export interface TreeItem {
|
||||
id: string;
|
||||
label: string;
|
||||
children?: TreeItem[];
|
||||
route?: string; // si défini, cliquer navigue au lieu de toggler
|
||||
isAction?: boolean; // style "action" (ex: "+ Nouveau chapitre")
|
||||
/** Clé d'icône optionnelle (ex: "users"). Résolue par le composant via `resolveIcon`. */
|
||||
iconKey?: string;
|
||||
/** Petit badge affiché à droite (ex: "3" pour compter les pages d'un dossier). */
|
||||
meta?: string;
|
||||
}
|
||||
|
||||
export interface GlobalItem {
|
||||
id: string;
|
||||
name: string;
|
||||
route: string;
|
||||
}
|
||||
|
||||
export interface SidebarAction {
|
||||
id: string;
|
||||
label: string;
|
||||
variant: 'primary' | 'secondary' | 'success';
|
||||
route?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Item affiché dans un panneau secondaire (ex: liste des Templates).
|
||||
* Plus simple qu'un TreeItem : pas de récursion, juste label + meta + route.
|
||||
*/
|
||||
export interface BottomPanelItem {
|
||||
id: string;
|
||||
label: string;
|
||||
meta?: string; // petit badge à droite (ex: "8 champs")
|
||||
route?: string;
|
||||
isAction?: boolean; // style "action" (ex: "+ Nouveau template")
|
||||
}
|
||||
|
||||
/**
|
||||
* Panneau secondaire collapsible en bas de la sidebar (sous l'arbre).
|
||||
* Utilisé notamment pour le panneau "Templates" côté Lore.
|
||||
*/
|
||||
export interface BottomPanel {
|
||||
id: string; // identifiant pour mémoriser l'état ouvert/fermé
|
||||
title: string;
|
||||
items: BottomPanelItem[];
|
||||
initiallyOpen?: boolean;
|
||||
}
|
||||
|
||||
export interface SecondarySidebarConfig {
|
||||
title: string;
|
||||
items: TreeItem[];
|
||||
createActions: SidebarAction[];
|
||||
globalItems: GlobalItem[];
|
||||
globalBackLabel: string;
|
||||
globalBackRoute: string;
|
||||
bottomPanel?: BottomPanel; // optionnel : présent côté Lore (Templates)
|
||||
/** @deprecated Remplacé par bottomPanel. Gardé pour compat des callers campagne. */
|
||||
footerLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service de layout — contrôle l'affichage de la secondary sidebar
|
||||
* et les données contextuelles de la sidebar globale.
|
||||
*
|
||||
* L'état d'expansion des items de l'arbre est maintenu au niveau du service
|
||||
* (et non dans le composant secondary-sidebar), pour survivre aux
|
||||
* destructions/recréations du composant lors des navigations (le *ngIf dans
|
||||
* app.component.html détruit la sidebar à chaque `hide()`).
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class LayoutService {
|
||||
private config$ = new BehaviorSubject<SecondarySidebarConfig | null>(null);
|
||||
private readonly expanded = new Set<string>();
|
||||
|
||||
readonly secondarySidebar$ = this.config$.asObservable();
|
||||
|
||||
show(config: SecondarySidebarConfig): void {
|
||||
this.config$.next(config);
|
||||
}
|
||||
|
||||
hide(): void {
|
||||
this.config$.next(null);
|
||||
}
|
||||
|
||||
isExpanded(id: string): boolean {
|
||||
return this.expanded.has(id);
|
||||
}
|
||||
|
||||
toggleExpanded(id: string): void {
|
||||
if (this.expanded.has(id)) this.expanded.delete(id);
|
||||
else this.expanded.add(id);
|
||||
}
|
||||
|
||||
setExpanded(id: string, state: boolean): void {
|
||||
if (state) this.expanded.add(id);
|
||||
else this.expanded.delete(id);
|
||||
}
|
||||
}
|
||||
37
web/src/app/services/lore.model.ts
Normal file
37
web/src/app/services/lore.model.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// Interface TypeScript pour LoreDTO (correspond au DTO Java)
|
||||
export interface Lore {
|
||||
id?: string;
|
||||
name: string;
|
||||
description: string;
|
||||
nodeCount?: number;
|
||||
pageCount?: number;
|
||||
}
|
||||
|
||||
// Interface pour la création de Lore (sans id)
|
||||
export interface LoreCreate {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface LoreNode {
|
||||
id?: string;
|
||||
name: string;
|
||||
/** Clé d'icône lucide-angular (ex: "users", "map-pin"). */
|
||||
icon?: string | null;
|
||||
/** ID du dossier parent (null = racine). */
|
||||
parentId?: string | null;
|
||||
loreId: string;
|
||||
/** Champs historiques non encore persistés côté backend — gardés pour compat de l'UI. */
|
||||
type?: string;
|
||||
description?: string;
|
||||
address?: string;
|
||||
}
|
||||
|
||||
export interface LoreNodeCreate {
|
||||
name: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
address: string;
|
||||
parentId?: string | null;
|
||||
loreId: string;
|
||||
}
|
||||
69
web/src/app/services/lore.service.ts
Normal file
69
web/src/app/services/lore.service.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Lore, LoreCreate, LoreNode, LoreNodeCreate } from './lore.model';
|
||||
|
||||
/**
|
||||
* Service HTTP pour la gestion des Lores.
|
||||
* Port de sortie vers le Backend Java (Architecture Hexagonale).
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class LoreService {
|
||||
private apiUrl = 'http://localhost:8080/api/lores';
|
||||
private nodesUrl = 'http://localhost:8080/api/lore-nodes';
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
getAllLores(): Observable<Lore[]> {
|
||||
return this.http.get<Lore[]>(this.apiUrl);
|
||||
}
|
||||
|
||||
getLoreById(id: string): Observable<Lore> {
|
||||
return this.http.get<Lore>(`${this.apiUrl}/${id}`);
|
||||
}
|
||||
|
||||
createLore(lore: LoreCreate): Observable<Lore> {
|
||||
return this.http.post<Lore>(this.apiUrl, lore);
|
||||
}
|
||||
|
||||
updateLore(id: string, lore: LoreCreate): Observable<Lore> {
|
||||
return this.http.put<Lore>(`${this.apiUrl}/${id}`, lore);
|
||||
}
|
||||
|
||||
deleteLore(id: string): Observable<void> {
|
||||
return this.http.delete<void>(`${this.apiUrl}/${id}`);
|
||||
}
|
||||
|
||||
getLoreNodes(loreId: string): Observable<LoreNode[]> {
|
||||
return this.http.get<LoreNode[]>(`${this.nodesUrl}?loreId=${loreId}`);
|
||||
}
|
||||
|
||||
getLoreNodeById(id: string): Observable<LoreNode> {
|
||||
return this.http.get<LoreNode>(`${this.nodesUrl}/${id}`);
|
||||
}
|
||||
|
||||
createLoreNode(node: LoreNodeCreate): Observable<LoreNode> {
|
||||
return this.http.post<LoreNode>(this.nodesUrl, node);
|
||||
}
|
||||
|
||||
/** PUT complet — envoie l'objet entier au backend (qui attend un LoreNodeDTO). */
|
||||
updateLoreNode(id: string, node: LoreNode): Observable<LoreNode> {
|
||||
return this.http.put<LoreNode>(`${this.nodesUrl}/${id}`, node);
|
||||
}
|
||||
|
||||
deleteLoreNode(id: string): Observable<void> {
|
||||
return this.http.delete<void>(`${this.nodesUrl}/${id}`);
|
||||
}
|
||||
|
||||
searchLores(q: string): Observable<Lore[]> {
|
||||
const params = new HttpParams().set('q', q);
|
||||
return this.http.get<Lore[]>(`${this.apiUrl}/search`, { params });
|
||||
}
|
||||
|
||||
searchLoreNodes(q: string): Observable<LoreNode[]> {
|
||||
const params = new HttpParams().set('q', q);
|
||||
return this.http.get<LoreNode[]>(`${this.nodesUrl}/search`, { params });
|
||||
}
|
||||
}
|
||||
25
web/src/app/services/page-title.service.ts
Normal file
25
web/src/app/services/page-title.service.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
|
||||
/**
|
||||
* Service centralisé pour le titre de l'onglet navigateur.
|
||||
* Uniformise le format "LoreMind - <sujet>" partout dans l'app.
|
||||
*
|
||||
* Pourquoi un wrapper et pas Title directement ? Évite de dupliquer le préfixe
|
||||
* "LoreMind - " dans chaque écran — si on veut changer le format un jour, un
|
||||
* seul endroit à toucher.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PageTitleService {
|
||||
constructor(private title: Title) {}
|
||||
|
||||
/**
|
||||
* Définit le titre de l'onglet au format "LoreMind - <subject>".
|
||||
* Passer `null` (ou vide) remet juste "LoreMind" — utile pour les écrans
|
||||
* listing qui n'ont pas de sujet spécifique.
|
||||
*/
|
||||
set(subject: string | null | undefined): void {
|
||||
const s = subject?.trim();
|
||||
this.title.setTitle(s ? `LoreMind - ${s}` : 'LoreMind');
|
||||
}
|
||||
}
|
||||
21
web/src/app/services/page.model.ts
Normal file
21
web/src/app/services/page.model.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// Interfaces TypeScript pour PageDTO (Backend Java).
|
||||
|
||||
export interface Page {
|
||||
id?: string;
|
||||
loreId: string;
|
||||
nodeId: string;
|
||||
templateId?: string | null;
|
||||
title: string;
|
||||
values?: Record<string, string>;
|
||||
notes?: string | null;
|
||||
tags?: string[];
|
||||
relatedPageIds?: string[];
|
||||
}
|
||||
|
||||
/** Payload de création : seuls les champs structurels sont envoyés. */
|
||||
export interface PageCreate {
|
||||
loreId: string;
|
||||
nodeId: string;
|
||||
templateId: string;
|
||||
title: string;
|
||||
}
|
||||
48
web/src/app/services/page.service.ts
Normal file
48
web/src/app/services/page.service.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Page, PageCreate } from './page.model';
|
||||
|
||||
/**
|
||||
* Service HTTP pour la gestion des Pages.
|
||||
* Port de sortie du Frontend vers le Backend Java (/api/pages).
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PageService {
|
||||
private apiUrl = 'http://localhost:8080/api/pages';
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
/** Toutes les pages d'un Lore (flat, pour répartir ensuite par nodeId). */
|
||||
getByLoreId(loreId: string): Observable<Page[]> {
|
||||
const params = new HttpParams().set('loreId', loreId);
|
||||
return this.http.get<Page[]>(this.apiUrl, { params });
|
||||
}
|
||||
|
||||
/** Toutes les pages d'un noeud donné. */
|
||||
getByNodeId(nodeId: string): Observable<Page[]> {
|
||||
const params = new HttpParams().set('nodeId', nodeId);
|
||||
return this.http.get<Page[]>(this.apiUrl, { params });
|
||||
}
|
||||
|
||||
getById(id: string): Observable<Page> {
|
||||
return this.http.get<Page>(`${this.apiUrl}/${id}`);
|
||||
}
|
||||
|
||||
create(payload: PageCreate): Observable<Page> {
|
||||
return this.http.post<Page>(this.apiUrl, payload);
|
||||
}
|
||||
|
||||
update(id: string, page: Page): Observable<Page> {
|
||||
return this.http.put<Page>(`${this.apiUrl}/${id}`, page);
|
||||
}
|
||||
|
||||
delete(id: string): Observable<void> {
|
||||
return this.http.delete<void>(`${this.apiUrl}/${id}`);
|
||||
}
|
||||
|
||||
search(q: string): Observable<Page[]> {
|
||||
const params = new HttpParams().set('q', q);
|
||||
return this.http.get<Page[]>(`${this.apiUrl}/search`, { params });
|
||||
}
|
||||
}
|
||||
20
web/src/app/services/template.model.ts
Normal file
20
web/src/app/services/template.model.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
// Interfaces TypeScript pour TemplateDTO (Backend Java).
|
||||
|
||||
export interface Template {
|
||||
id?: string;
|
||||
loreId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
defaultNodeId?: string | null;
|
||||
fields: string[];
|
||||
fieldCount?: number;
|
||||
}
|
||||
|
||||
/** Payload de création : id absent, fieldCount absent (calculé côté serveur). */
|
||||
export interface TemplateCreate {
|
||||
loreId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
defaultNodeId?: string | null;
|
||||
fields: string[];
|
||||
}
|
||||
42
web/src/app/services/template.service.ts
Normal file
42
web/src/app/services/template.service.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Template, TemplateCreate } from './template.model';
|
||||
|
||||
/**
|
||||
* Service HTTP pour la gestion des Templates.
|
||||
* Port de sortie du Frontend vers le Backend Java (/api/templates).
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class TemplateService {
|
||||
private apiUrl = 'http://localhost:8080/api/templates';
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
/** Tous les templates d'un Lore (alimente le panneau sidebar). */
|
||||
getByLoreId(loreId: string): Observable<Template[]> {
|
||||
const params = new HttpParams().set('loreId', loreId);
|
||||
return this.http.get<Template[]>(this.apiUrl, { params });
|
||||
}
|
||||
|
||||
getById(id: string): Observable<Template> {
|
||||
return this.http.get<Template>(`${this.apiUrl}/${id}`);
|
||||
}
|
||||
|
||||
create(payload: TemplateCreate): Observable<Template> {
|
||||
return this.http.post<Template>(this.apiUrl, payload);
|
||||
}
|
||||
|
||||
update(id: string, template: Template): Observable<Template> {
|
||||
return this.http.put<Template>(`${this.apiUrl}/${id}`, template);
|
||||
}
|
||||
|
||||
delete(id: string): Observable<void> {
|
||||
return this.http.delete<void>(`${this.apiUrl}/${id}`);
|
||||
}
|
||||
|
||||
search(q: string): Observable<Template[]> {
|
||||
const params = new HttpParams().set('q', q);
|
||||
return this.http.get<Template[]>(`${this.apiUrl}/search`, { params });
|
||||
}
|
||||
}
|
||||
12
web/src/app/shared/breadcrumb/breadcrumb.component.html
Normal file
12
web/src/app/shared/breadcrumb/breadcrumb.component.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<nav class="breadcrumb" aria-label="Fil d'Ariane" *ngIf="items.length">
|
||||
<ol>
|
||||
<li *ngFor="let item of items; let last = last; trackBy: trackByIndex"
|
||||
class="breadcrumb-item"
|
||||
[class.current]="last">
|
||||
<a *ngIf="item.route && !last"
|
||||
[routerLink]="item.route"
|
||||
class="breadcrumb-link">{{ item.label }}</a>
|
||||
<span *ngIf="!item.route || last" class="breadcrumb-text">{{ item.label }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
50
web/src/app/shared/breadcrumb/breadcrumb.component.scss
Normal file
50
web/src/app/shared/breadcrumb/breadcrumb.component.scss
Normal file
@@ -0,0 +1,50 @@
|
||||
.breadcrumb {
|
||||
font-size: 0.82rem;
|
||||
color: #6b7280;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
ol {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.breadcrumb-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
|
||||
&::before {
|
||||
content: '›';
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
&:first-child::before {
|
||||
content: none;
|
||||
}
|
||||
|
||||
&.current .breadcrumb-text {
|
||||
color: #e5e7eb;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.breadcrumb-link {
|
||||
color: #9ca3af;
|
||||
text-decoration: none;
|
||||
transition: color 0.15s;
|
||||
|
||||
&:hover {
|
||||
color: #a5b4fc;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.breadcrumb-text {
|
||||
color: inherit;
|
||||
}
|
||||
35
web/src/app/shared/breadcrumb/breadcrumb.component.ts
Normal file
35
web/src/app/shared/breadcrumb/breadcrumb.component.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
/**
|
||||
* Un niveau dans le fil d'Ariane.
|
||||
* Si `route` est défini, l'item est cliquable (navigation).
|
||||
* Sinon, c'est la position courante (dernier niveau, non-cliquable).
|
||||
*/
|
||||
export interface BreadcrumbItem {
|
||||
label: string;
|
||||
route?: string | any[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Composant réutilisable de fil d'Ariane.
|
||||
* Utilisation type :
|
||||
* <app-breadcrumb [items]="[
|
||||
* { label: 'Mon Univers', route: ['/lore', loreId] },
|
||||
* { label: 'PNJ', route: ['/lore', loreId, 'folders', nodeId, 'edit'] },
|
||||
* { label: 'Aldric' }
|
||||
* ]"></app-breadcrumb>
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-breadcrumb',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink],
|
||||
templateUrl: './breadcrumb.component.html',
|
||||
styleUrls: ['./breadcrumb.component.scss']
|
||||
})
|
||||
export class BreadcrumbComponent {
|
||||
@Input() items: BreadcrumbItem[] = [];
|
||||
|
||||
trackByIndex = (i: number) => i;
|
||||
}
|
||||
15
web/src/app/shared/chips-input/chips-input.component.html
Normal file
15
web/src/app/shared/chips-input/chips-input.component.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<div class="chips-wrapper">
|
||||
<span class="chip" *ngFor="let tag of value; let i = index">
|
||||
{{ tag }}
|
||||
<button type="button" class="chip-remove" (click)="remove(i)" title="Retirer">
|
||||
<lucide-icon [img]="X" [size]="12"></lucide-icon>
|
||||
</button>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
class="chip-input"
|
||||
[placeholder]="placeholder"
|
||||
[(ngModel)]="current"
|
||||
(keydown)="onKeyDown($event)"
|
||||
(blur)="add()" />
|
||||
</div>
|
||||
53
web/src/app/shared/chips-input/chips-input.component.scss
Normal file
53
web/src/app/shared/chips-input/chips-input.component.scss
Normal file
@@ -0,0 +1,53 @@
|
||||
.chips-wrapper {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.55rem 0.65rem;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
border-radius: 6px;
|
||||
|
||||
&:focus-within {
|
||||
border-color: #6c63ff;
|
||||
}
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.2rem 0.55rem;
|
||||
background: #1e1c3a;
|
||||
color: #a5b4fc;
|
||||
border-radius: 999px;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.2;
|
||||
|
||||
.chip-remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
opacity: 0.7;
|
||||
|
||||
&:hover { opacity: 1; color: #fca5a5; }
|
||||
}
|
||||
}
|
||||
|
||||
.chip-input {
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 0.88rem;
|
||||
padding: 0.15rem 0;
|
||||
outline: none;
|
||||
|
||||
&::placeholder { color: #6b7280; }
|
||||
}
|
||||
68
web/src/app/shared/chips-input/chips-input.component.ts
Normal file
68
web/src/app/shared/chips-input/chips-input.component.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Component, Input, Output, EventEmitter } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { LucideAngularModule, X } from 'lucide-angular';
|
||||
|
||||
/**
|
||||
* Composant réutilisable de saisie de chips (étiquettes textuelles).
|
||||
*
|
||||
* Usage :
|
||||
* <app-chips-input
|
||||
* [value]="tags"
|
||||
* (valueChange)="tags = $event"
|
||||
* placeholder="Ajouter un tag..."></app-chips-input>
|
||||
*
|
||||
* Règles UX :
|
||||
* - Entrée valide la saisie en cours
|
||||
* - Virgule valide aussi (pratique pour enchaîner plusieurs tags)
|
||||
* - Doublons silencieusement ignorés
|
||||
* - Espaces en début/fin trimmés
|
||||
* - Clic sur X retire le chip
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-chips-input',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, LucideAngularModule],
|
||||
templateUrl: './chips-input.component.html',
|
||||
styleUrls: ['./chips-input.component.scss']
|
||||
})
|
||||
export class ChipsInputComponent {
|
||||
readonly X = X;
|
||||
|
||||
@Input() value: string[] = [];
|
||||
@Input() placeholder = 'Ajouter...';
|
||||
@Output() valueChange = new EventEmitter<string[]>();
|
||||
|
||||
/** Texte de l'input en cours de saisie. */
|
||||
current = '';
|
||||
|
||||
/** Ajoute le texte courant s'il est non vide et non déjà présent. */
|
||||
add(): void {
|
||||
const raw = this.current.trim().replace(/,$/, '').trim();
|
||||
if (!raw) return;
|
||||
if (this.value.includes(raw)) {
|
||||
this.current = '';
|
||||
return;
|
||||
}
|
||||
this.valueChange.emit([...this.value, raw]);
|
||||
this.current = '';
|
||||
}
|
||||
|
||||
/** Retire le chip à l'index donné. */
|
||||
remove(index: number): void {
|
||||
const next = [...this.value];
|
||||
next.splice(index, 1);
|
||||
this.valueChange.emit(next);
|
||||
}
|
||||
|
||||
/** Gère Enter et virgule comme déclencheurs d'ajout. */
|
||||
onKeyDown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Enter' || event.key === ',') {
|
||||
event.preventDefault();
|
||||
this.add();
|
||||
} else if (event.key === 'Backspace' && this.current === '' && this.value.length > 0) {
|
||||
// Backspace sur input vide retire le dernier chip (UX courante).
|
||||
this.remove(this.value.length - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<div class="expandable-section" [class.private]="variant === 'private'">
|
||||
|
||||
<button type="button" class="section-header" (click)="toggle()">
|
||||
<span class="section-title">
|
||||
<span class="section-icon" *ngIf="icon">{{ icon }}</span>
|
||||
{{ title }}
|
||||
</span>
|
||||
<lucide-icon [img]="isOpen ? ChevronUp : ChevronDown" [size]="16"></lucide-icon>
|
||||
</button>
|
||||
|
||||
<div class="section-content" *ngIf="isOpen">
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,49 @@
|
||||
.expandable-section {
|
||||
background: #1f2937;
|
||||
border: 1px solid #374151;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
&.private {
|
||||
border-color: #7f1d1d;
|
||||
|
||||
.section-header { background: rgba(127, 29, 29, 0.12); }
|
||||
.section-title { color: #fca5a5; }
|
||||
}
|
||||
}
|
||||
|
||||
.section-header {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.9rem 1.1rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: #d1d5db;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover { background: #374151; }
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.section-content {
|
||||
padding: 1rem 1.1rem 1.2rem 1.1rem;
|
||||
border-top: 1px solid #374151;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { LucideAngularModule, ChevronDown, ChevronUp } from 'lucide-angular';
|
||||
|
||||
/**
|
||||
* Section repliable avec titre (icône + texte) et contenu projeté via ng-content.
|
||||
* Utilisé dans les écrans d'édition de Scene pour structurer les champs narratifs.
|
||||
*
|
||||
* Usage :
|
||||
* <app-expandable-section title="Contexte et ambiance" icon="📍" [initiallyOpen]="true">
|
||||
* <!-- champs de formulaire -->
|
||||
* </app-expandable-section>
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-expandable-section',
|
||||
standalone: true,
|
||||
imports: [CommonModule, LucideAngularModule],
|
||||
templateUrl: './expandable-section.component.html',
|
||||
styleUrls: ['./expandable-section.component.scss']
|
||||
})
|
||||
export class ExpandableSectionComponent {
|
||||
readonly ChevronDown = ChevronDown;
|
||||
readonly ChevronUp = ChevronUp;
|
||||
|
||||
@Input() title = '';
|
||||
@Input() icon = ''; // Emoji ou caractère unicode (ex: '📍', '📖')
|
||||
@Input() initiallyOpen = false;
|
||||
@Input() variant: 'default' | 'private' = 'default'; // 'private' = notes MJ (couleur différente)
|
||||
|
||||
isOpen = false;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.isOpen = this.initiallyOpen;
|
||||
}
|
||||
|
||||
toggle(): void {
|
||||
this.isOpen = !this.isOpen;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<div class="gs-overlay" *ngIf="open" (click)="close()">
|
||||
<div class="gs-modal" (click)="$event.stopPropagation()">
|
||||
|
||||
<div class="gs-input-row">
|
||||
<lucide-icon [img]="Search" [size]="18" class="gs-search-icon"></lucide-icon>
|
||||
<input
|
||||
#searchInput
|
||||
type="text"
|
||||
class="gs-input"
|
||||
placeholder="Rechercher dans LoreMind..."
|
||||
[ngModel]="query"
|
||||
(ngModelChange)="onQueryChange($event)" />
|
||||
<span class="gs-kbd">ESC</span>
|
||||
</div>
|
||||
|
||||
<div class="gs-results" *ngIf="results.length > 0">
|
||||
<button
|
||||
*ngFor="let r of results; let i = index"
|
||||
class="gs-result"
|
||||
[class.active]="i === activeIndex"
|
||||
(mouseenter)="activeIndex = i"
|
||||
(click)="select(r)">
|
||||
<lucide-icon [img]="iconFor(r.kind)" [size]="16" class="gs-result-icon"></lucide-icon>
|
||||
<div class="gs-result-body">
|
||||
<div class="gs-result-title">{{ r.title }}</div>
|
||||
<div class="gs-result-subtitle" *ngIf="r.subtitle">{{ r.subtitle }}</div>
|
||||
<div class="gs-result-tag">{{ r.tag }}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="gs-empty" *ngIf="!loading && query.trim().length >= 2 && results.length === 0">
|
||||
Aucun résultat
|
||||
</div>
|
||||
<div class="gs-empty" *ngIf="query.trim().length < 2">
|
||||
Tape au moins 2 caractères
|
||||
</div>
|
||||
|
||||
<div class="gs-footer">
|
||||
<div class="gs-hints">
|
||||
<span><kbd>↑</kbd><kbd>↓</kbd> naviguer</span>
|
||||
<span><kbd>↵</kbd> sélectionner</span>
|
||||
<span><kbd>ESC</kbd> fermer</span>
|
||||
</div>
|
||||
<span class="gs-count" *ngIf="results.length > 0">
|
||||
{{ results.length }} résultat{{ results.length > 1 ? 's' : '' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
118
web/src/app/shared/global-search/global-search.component.scss
Normal file
118
web/src/app/shared/global-search/global-search.component.scss
Normal file
@@ -0,0 +1,118 @@
|
||||
.gs-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding-top: 10vh;
|
||||
}
|
||||
|
||||
.gs-modal {
|
||||
width: min(640px, 92vw);
|
||||
background: #1f2232;
|
||||
border: 1px solid #2d3145;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gs-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid #2d3145;
|
||||
}
|
||||
|
||||
.gs-search-icon { color: #6b7280; flex-shrink: 0; }
|
||||
|
||||
.gs-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: #e5e7eb;
|
||||
font-size: 1rem;
|
||||
|
||||
&::placeholder { color: #6b7280; }
|
||||
}
|
||||
|
||||
.gs-kbd,
|
||||
.gs-hints kbd {
|
||||
background: #2d3145;
|
||||
color: #9ca3af;
|
||||
font-size: 0.7rem;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #3a3f55;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.gs-results {
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.gs-result {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #e5e7eb;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
|
||||
&.active { background: rgba(99, 102, 241, 0.18); }
|
||||
}
|
||||
|
||||
.gs-result-icon { color: #9ca3af; margin-top: 2px; flex-shrink: 0; }
|
||||
|
||||
.gs-result-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.gs-result-title { font-weight: 600; color: #e5e7eb; }
|
||||
.gs-result-subtitle {
|
||||
color: #9ca3af;
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.gs-result-tag {
|
||||
color: #6b7280;
|
||||
font-size: 0.75rem;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.gs-empty {
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.gs-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.6rem 1.25rem;
|
||||
border-top: 1px solid #2d3145;
|
||||
font-size: 0.75rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.gs-hints {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
|
||||
span { display: flex; align-items: center; gap: 0.3rem; }
|
||||
}
|
||||
216
web/src/app/shared/global-search/global-search.component.ts
Normal file
216
web/src/app/shared/global-search/global-search.component.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { Component, ElementRef, HostListener, OnDestroy, OnInit, ViewChild } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { BehaviorSubject, Subject, forkJoin, of } from 'rxjs';
|
||||
import { catchError, debounceTime, distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
|
||||
import { LucideAngularModule, Search, BookOpen, Folder, Users, FileText, Scroll } from 'lucide-angular';
|
||||
import { GlobalSearchService } from '../../services/global-search.service';
|
||||
import { LoreService } from '../../services/lore.service';
|
||||
import { PageService } from '../../services/page.service';
|
||||
import { TemplateService } from '../../services/template.service';
|
||||
import { CampaignService } from '../../services/campaign.service';
|
||||
|
||||
type ResultKind = 'lore' | 'node' | 'template' | 'page' | 'campaign';
|
||||
|
||||
interface SearchResult {
|
||||
id: string;
|
||||
kind: ResultKind;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
/** Tag affiché sous le titre (ex: "Lore", "Dossier", "Template", "Page"). */
|
||||
tag: string;
|
||||
/** Route Angular (array pour router.navigate). */
|
||||
route: any[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Command palette globale (Ctrl+K / Cmd+K).
|
||||
* Agrège 4 endpoints search (Lore, LoreNode, Template, Page) côté frontend.
|
||||
* Navigation clavier : ↑↓ ↵ Esc.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-global-search',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, LucideAngularModule],
|
||||
templateUrl: './global-search.component.html',
|
||||
styleUrls: ['./global-search.component.scss']
|
||||
})
|
||||
export class GlobalSearchComponent implements OnInit, OnDestroy {
|
||||
readonly Search = Search;
|
||||
readonly BookOpen = BookOpen;
|
||||
readonly Folder = Folder;
|
||||
readonly Users = Users;
|
||||
readonly FileText = FileText;
|
||||
readonly Scroll = Scroll;
|
||||
|
||||
@ViewChild('searchInput') searchInput?: ElementRef<HTMLInputElement>;
|
||||
|
||||
open = false;
|
||||
query = '';
|
||||
loading = false;
|
||||
results: SearchResult[] = [];
|
||||
activeIndex = 0;
|
||||
|
||||
private readonly queryChanges$ = new BehaviorSubject<string>('');
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
|
||||
constructor(
|
||||
private globalSearch: GlobalSearchService,
|
||||
private router: Router,
|
||||
private loreService: LoreService,
|
||||
private pageService: PageService,
|
||||
private templateService: TemplateService,
|
||||
private campaignService: CampaignService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.globalSearch.open$.pipe(takeUntil(this.destroy$)).subscribe(open => {
|
||||
this.open = open;
|
||||
if (open) {
|
||||
this.query = '';
|
||||
this.results = [];
|
||||
this.activeIndex = 0;
|
||||
// focus après le tick de rendu (ngIf du template)
|
||||
setTimeout(() => this.searchInput?.nativeElement.focus(), 0);
|
||||
}
|
||||
});
|
||||
|
||||
this.queryChanges$.pipe(
|
||||
debounceTime(200),
|
||||
distinctUntilChanged(),
|
||||
switchMap(q => {
|
||||
const trimmed = q.trim();
|
||||
if (trimmed.length < 2) {
|
||||
this.loading = false;
|
||||
return of<SearchResult[]>([]);
|
||||
}
|
||||
this.loading = true;
|
||||
return forkJoin({
|
||||
lores: this.loreService.searchLores(trimmed).pipe(catchError(() => of([]))),
|
||||
nodes: this.loreService.searchLoreNodes(trimmed).pipe(catchError(() => of([]))),
|
||||
templates: this.templateService.search(trimmed).pipe(catchError(() => of([]))),
|
||||
pages: this.pageService.search(trimmed).pipe(catchError(() => of([]))),
|
||||
campaigns: this.campaignService.search(trimmed).pipe(catchError(() => of([])))
|
||||
}).pipe(
|
||||
switchMap(({ lores, nodes, templates, pages, campaigns }) => of(this.buildResults(lores, nodes, templates, pages, campaigns))),
|
||||
catchError(() => of<SearchResult[]>([]))
|
||||
);
|
||||
}),
|
||||
takeUntil(this.destroy$)
|
||||
).subscribe(results => {
|
||||
this.results = results;
|
||||
this.activeIndex = 0;
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
onQueryChange(value: string): void {
|
||||
this.query = value;
|
||||
this.queryChanges$.next(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit la liste unifiée. Ordre d'affichage : pages d'abord (le plus recherché),
|
||||
* puis noeuds, templates, campagnes, et lores — les entités racines apparaissent en dernier.
|
||||
*/
|
||||
private buildResults(
|
||||
lores: any[], nodes: any[], templates: any[], pages: any[], campaigns: any[]
|
||||
): SearchResult[] {
|
||||
const pageResults: SearchResult[] = pages.map(p => ({
|
||||
id: p.id,
|
||||
kind: 'page' as ResultKind,
|
||||
title: p.title,
|
||||
subtitle: p.notes ? this.firstLine(p.notes) : '',
|
||||
tag: 'Page',
|
||||
route: ['/lore', p.loreId, 'pages', p.id]
|
||||
}));
|
||||
const nodeResults: SearchResult[] = nodes.map(n => ({
|
||||
id: n.id,
|
||||
kind: 'node' as ResultKind,
|
||||
title: n.name,
|
||||
subtitle: '',
|
||||
tag: 'Dossier',
|
||||
route: ['/lore', n.loreId, 'folders', n.id, 'edit']
|
||||
}));
|
||||
const templateResults: SearchResult[] = templates.map(t => ({
|
||||
id: t.id,
|
||||
kind: 'template' as ResultKind,
|
||||
title: t.name,
|
||||
subtitle: t.description ?? '',
|
||||
tag: 'Template',
|
||||
route: ['/lore', t.loreId, 'templates', t.id]
|
||||
}));
|
||||
const loreResults: SearchResult[] = lores.map(l => ({
|
||||
id: l.id,
|
||||
kind: 'lore' as ResultKind,
|
||||
title: l.name,
|
||||
subtitle: l.description ?? '',
|
||||
tag: 'Lore',
|
||||
route: ['/lore', l.id]
|
||||
}));
|
||||
const campaignResults: SearchResult[] = campaigns.map(c => ({
|
||||
id: c.id,
|
||||
kind: 'campaign' as ResultKind,
|
||||
title: c.name,
|
||||
subtitle: c.description ?? '',
|
||||
tag: 'Campagne',
|
||||
route: ['/campaigns', c.id]
|
||||
}));
|
||||
return [...pageResults, ...nodeResults, ...templateResults, ...campaignResults, ...loreResults];
|
||||
}
|
||||
|
||||
private firstLine(text: string): string {
|
||||
const line = text.split('\n')[0] ?? '';
|
||||
return line.length > 120 ? line.slice(0, 117) + '…' : line;
|
||||
}
|
||||
|
||||
select(result: SearchResult): void {
|
||||
this.router.navigate(result.route);
|
||||
this.globalSearch.close();
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.globalSearch.close();
|
||||
}
|
||||
|
||||
/** Raccourcis clavier globaux — actifs uniquement quand la modale est ouverte. */
|
||||
@HostListener('document:keydown', ['$event'])
|
||||
onKeydown(event: KeyboardEvent): void {
|
||||
if (!this.open) return;
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
this.close();
|
||||
} else if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
if (this.results.length > 0) {
|
||||
this.activeIndex = (this.activeIndex + 1) % this.results.length;
|
||||
}
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
if (this.results.length > 0) {
|
||||
this.activeIndex = (this.activeIndex - 1 + this.results.length) % this.results.length;
|
||||
}
|
||||
} else if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
const r = this.results[this.activeIndex];
|
||||
if (r) this.select(r);
|
||||
}
|
||||
}
|
||||
|
||||
iconFor(kind: ResultKind) {
|
||||
switch (kind) {
|
||||
case 'lore': return this.BookOpen;
|
||||
case 'node': return this.Folder;
|
||||
case 'template': return this.Users;
|
||||
case 'page': return this.FileText;
|
||||
case 'campaign': return this.Scroll;
|
||||
default: return this.FileText;
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<div class="picker">
|
||||
|
||||
<!-- Chips des pages déjà liées -->
|
||||
<div class="linked-chips" *ngIf="linkedPages.length > 0">
|
||||
<span class="chip" *ngFor="let p of linkedPages">
|
||||
<a
|
||||
class="chip-label"
|
||||
[href]="pageUrl(p.id!)"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
title="Ouvrir dans un nouvel onglet">
|
||||
<lucide-icon [img]="Link2" [size]="12"></lucide-icon>
|
||||
{{ p.title }}
|
||||
</a>
|
||||
<button type="button" class="chip-remove" (click)="remove(p.id!)" title="Retirer le lien">
|
||||
<lucide-icon [img]="X" [size]="12"></lucide-icon>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Input + dropdown de suggestions -->
|
||||
<div class="search-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="Rechercher une page à lier..."
|
||||
[(ngModel)]="query"
|
||||
(focus)="dropdownOpen = true"
|
||||
(blur)="onBlur()" />
|
||||
|
||||
<ul class="suggestions" *ngIf="dropdownOpen && suggestions.length > 0">
|
||||
<li *ngFor="let p of suggestions" (mousedown)="add(p)">
|
||||
{{ p.title }}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p class="empty-hint" *ngIf="dropdownOpen && query && suggestions.length === 0">
|
||||
Aucune page ne correspond
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,114 @@
|
||||
.picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.linked-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: #1e1c3a;
|
||||
color: #a5b4fc;
|
||||
border-radius: 999px;
|
||||
font-size: 0.82rem;
|
||||
overflow: hidden;
|
||||
|
||||
.chip-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.25rem 0.6rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover { color: #c7d2fe; }
|
||||
}
|
||||
|
||||
.chip-remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-left: 1px solid #2a2a3d;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
|
||||
&:hover { opacity: 1; color: #fca5a5; background: #2a2a3d; }
|
||||
}
|
||||
}
|
||||
|
||||
.search-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
color: white;
|
||||
padding: 0.65rem 0.9rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.88rem;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #6c63ff;
|
||||
}
|
||||
|
||||
&::placeholder { color: #6b7280; }
|
||||
}
|
||||
|
||||
.suggestions {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
list-style: none;
|
||||
padding: 0.25rem 0;
|
||||
margin: 0;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
z-index: 10;
|
||||
|
||||
li {
|
||||
padding: 0.55rem 0.9rem;
|
||||
color: #d1d5db;
|
||||
font-size: 0.88rem;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover { background: #20203a; color: white; }
|
||||
}
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 0.55rem 0.9rem;
|
||||
margin: 0;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
border-radius: 6px;
|
||||
color: #6b7280;
|
||||
font-size: 0.82rem;
|
||||
font-style: italic;
|
||||
z-index: 10;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Component, Input, Output, EventEmitter } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { LucideAngularModule, X, Link2 } from 'lucide-angular';
|
||||
import { Page } from '../../services/page.model';
|
||||
|
||||
/**
|
||||
* Composant pour lier une entité (Page, Arc, Chapter, Scene...) à un ensemble
|
||||
* de Pages du Lore par leurs IDs.
|
||||
*
|
||||
* Prévu pour être réutilisé en Phase cross-context Campagne↔Lore (sous-tâche 4) :
|
||||
* un Arc/Chapter/Scene pourra pointer vers des Pages du Lore via ce même picker.
|
||||
*
|
||||
* Usage :
|
||||
* <app-lore-link-picker
|
||||
* [value]="relatedPageIds"
|
||||
* [availablePages]="allPages"
|
||||
* [excludePageId]="currentPageId"
|
||||
* [loreId]="loreId"
|
||||
* (valueChange)="relatedPageIds = $event"></app-lore-link-picker>
|
||||
*
|
||||
* Design :
|
||||
* - Liste des pages liées = chips cliquables (clic → navigation vers la page)
|
||||
* - Input de recherche avec dropdown de suggestions filtrées (max 8 résultats)
|
||||
* - Exclut la page courante et les pages déjà sélectionnées
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-lore-link-picker',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, LucideAngularModule],
|
||||
templateUrl: './lore-link-picker.component.html',
|
||||
styleUrls: ['./lore-link-picker.component.scss']
|
||||
})
|
||||
export class LoreLinkPickerComponent {
|
||||
readonly X = X;
|
||||
readonly Link2 = Link2;
|
||||
|
||||
/** IDs des pages actuellement liées (contrôlées par le parent). */
|
||||
@Input() value: string[] = [];
|
||||
/** Référentiel de pages dans lequel on peut piocher. */
|
||||
@Input() availablePages: Page[] = [];
|
||||
/** ID de la page courante (exclue des suggestions) — optionnel. */
|
||||
@Input() excludePageId: string | null = null;
|
||||
/** ID du lore courant, utilisé pour construire les URLs des chips cliquables. */
|
||||
@Input() loreId = '';
|
||||
|
||||
@Output() valueChange = new EventEmitter<string[]>();
|
||||
|
||||
/** Texte de recherche courant. */
|
||||
query = '';
|
||||
/** true tant que l'input a le focus (pour afficher le dropdown). */
|
||||
dropdownOpen = false;
|
||||
|
||||
/** Pages actuellement liées (résolues en objets complets pour affichage). */
|
||||
get linkedPages(): Page[] {
|
||||
return this.value
|
||||
.map(id => this.availablePages.find(p => p.id === id))
|
||||
.filter((p): p is Page => !!p);
|
||||
}
|
||||
|
||||
/** Pages proposables dans le dropdown — filtrées par query, exclut current + déjà liées. */
|
||||
get suggestions(): Page[] {
|
||||
const q = this.query.trim().toLowerCase();
|
||||
return this.availablePages
|
||||
.filter(p => p.id !== this.excludePageId)
|
||||
.filter(p => !this.value.includes(p.id!))
|
||||
.filter(p => q === '' || p.title.toLowerCase().includes(q))
|
||||
.slice(0, 8);
|
||||
}
|
||||
|
||||
/** Ajoute une page aux liens. */
|
||||
add(page: Page): void {
|
||||
if (!page.id || this.value.includes(page.id)) return;
|
||||
this.valueChange.emit([...this.value, page.id]);
|
||||
this.query = '';
|
||||
}
|
||||
|
||||
/** Retire une page des liens. */
|
||||
remove(pageId: string): void {
|
||||
this.valueChange.emit(this.value.filter(id => id !== pageId));
|
||||
}
|
||||
|
||||
/**
|
||||
* URL vers la page liée — utilisée par un <a target="_blank"> dans le template.
|
||||
* Ouverture en nouvel onglet : on consulte la fiche d'un PNJ/lieu sans perdre
|
||||
* le contexte de la page/scène en cours d'édition.
|
||||
*/
|
||||
pageUrl(pageId: string): string {
|
||||
return `/lore/${this.loreId}/pages/${pageId}`;
|
||||
}
|
||||
|
||||
/** Retarde la fermeture du dropdown pour laisser le temps au clic de se propager. */
|
||||
onBlur(): void {
|
||||
setTimeout(() => { this.dropdownOpen = false; }, 150);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<aside class="secondary-sidebar" [class.collapsed]="isCollapsed">
|
||||
|
||||
<div class="collapse-toggle" (click)="toggleCollapse()">
|
||||
<lucide-icon [img]="isCollapsed ? PanelLeftOpen : PanelLeftClose" [size]="16"></lucide-icon>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="!isCollapsed">
|
||||
|
||||
<h2 class="sidebar-title">{{ title }}</h2>
|
||||
|
||||
<div class="actions-row" *ngIf="createActions.length">
|
||||
<button
|
||||
*ngFor="let action of createActions"
|
||||
class="btn-pill"
|
||||
[class.primary]="action.variant === 'primary'"
|
||||
[class.secondary]="action.variant === 'secondary'"
|
||||
(click)="runAction(action)">
|
||||
{{ action.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tree">
|
||||
<ng-container *ngFor="let item of items">
|
||||
<ng-container *ngTemplateOutlet="treeNode; context: { $implicit: item, level: 0 }"></ng-container>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<!-- Template récursif : un noeud d'arbre rend son bouton, puis ses enfants via ce même template -->
|
||||
<ng-template #treeNode let-item let-level="level">
|
||||
<div class="tree-item" [style.padding-left.px]="level * 12">
|
||||
<button class="tree-btn" [class.action]="item.isAction" (click)="clickItem(item)">
|
||||
<span
|
||||
*ngIf="!item.isAction && item.children?.length"
|
||||
class="chevron-zone"
|
||||
(click)="clickChevron($event, item)">
|
||||
<lucide-icon
|
||||
[img]="isExpanded(item.id) ? ChevronDown : ChevronRight"
|
||||
[size]="12">
|
||||
</lucide-icon>
|
||||
</span>
|
||||
<lucide-icon
|
||||
*ngIf="iconFor(item) as icon"
|
||||
[img]="icon"
|
||||
[size]="14"
|
||||
class="item-icon">
|
||||
</lucide-icon>
|
||||
{{ item.label }}
|
||||
<span class="tree-item-meta" *ngIf="!item.isAction && item.meta">{{ item.meta }}</span>
|
||||
</button>
|
||||
<div class="tree-children" *ngIf="isExpanded(item.id) && item.children?.length">
|
||||
<ng-container *ngFor="let child of item.children">
|
||||
<ng-container *ngTemplateOutlet="treeNode; context: { $implicit: child, level: level + 1 }"></ng-container>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<!-- Panneau bas (ex: Templates) ------------------------------------ -->
|
||||
<section class="bottom-panel" *ngIf="bottomPanel">
|
||||
<button class="panel-header" (click)="togglePanel()">
|
||||
<span class="panel-title">{{ bottomPanel.title }}</span>
|
||||
<lucide-icon
|
||||
[img]="panelOpen ? ChevronDown : ChevronRight"
|
||||
[size]="14">
|
||||
</lucide-icon>
|
||||
</button>
|
||||
<ul class="panel-list" *ngIf="panelOpen">
|
||||
<li *ngFor="let item of bottomPanel.items">
|
||||
<button
|
||||
class="panel-item"
|
||||
[class.action]="item.isAction"
|
||||
(click)="clickPanelItem(item)">
|
||||
<span class="panel-item-label">{{ item.label }}</span>
|
||||
<span class="panel-item-meta" *ngIf="item.meta">{{ item.meta }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
</ng-container>
|
||||
|
||||
</aside>
|
||||
@@ -0,0 +1,217 @@
|
||||
.secondary-sidebar {
|
||||
width: 220px;
|
||||
height: 100vh;
|
||||
background: #0d0d1f;
|
||||
border-right: 1px solid #1e1e3a;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.25rem 0.75rem;
|
||||
gap: 0.75rem;
|
||||
overflow-y: auto;
|
||||
transition: width 0.25s ease;
|
||||
|
||||
&.collapsed {
|
||||
width: 44px;
|
||||
padding: 1.25rem 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.collapse-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: #6b7280;
|
||||
transition: color 0.2s, background 0.2s;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover { color: white; background: #1f2937; }
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #6b7280;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
transition: color 0.2s;
|
||||
width: fit-content;
|
||||
|
||||
&:hover { color: white; }
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
padding: 0 0.5rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.actions-row {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.btn-pill {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem 0.6rem;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, filter 0.15s;
|
||||
|
||||
&.primary { background: #6c63ff; color: white; }
|
||||
&.primary:hover { background: #5a52e0; }
|
||||
&.secondary { background: #2a2a3d; color: #d1d5db; }
|
||||
&.secondary:hover { background: #363650; }
|
||||
}
|
||||
|
||||
.tree {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
overflow-y: auto;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.tree-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #d1d5db;
|
||||
font-size: 0.82rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover { background: #1f2937; }
|
||||
&.action { color: #6b7280; font-style: italic; }
|
||||
&.action:hover { color: #a5b4fc; background: #1f2937; }
|
||||
|
||||
.tree-item-meta {
|
||||
margin-left: auto;
|
||||
font-size: 0.72rem;
|
||||
color: #6b7280;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.item-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: #a5b4fc;
|
||||
flex-shrink: 0;
|
||||
margin-right: 0.1rem;
|
||||
}
|
||||
|
||||
.chevron-zone {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
color: #6b7280;
|
||||
|
||||
&:hover { background: #374151; color: white; }
|
||||
}
|
||||
|
||||
.tree-children {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
|
||||
/* --- Bottom panel (ex: Templates) ------------------------------------- */
|
||||
.bottom-panel {
|
||||
border-top: 1px solid #1e1e3a;
|
||||
padding-top: 0.5rem;
|
||||
margin-top: auto; /* colle en bas de la sidebar flex */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #a5b4fc;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover { background: #1f2937; }
|
||||
|
||||
.panel-title { letter-spacing: 0.02em; }
|
||||
}
|
||||
|
||||
.panel-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.panel-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #d1d5db;
|
||||
font-size: 0.82rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover { background: #1f2937; }
|
||||
|
||||
&.action {
|
||||
color: #6b7280;
|
||||
font-style: italic;
|
||||
}
|
||||
&.action:hover { color: #a5b4fc; }
|
||||
|
||||
.panel-item-meta {
|
||||
font-size: 0.72rem;
|
||||
color: #6b7280;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Component, Input, Output, EventEmitter } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
import { LucideAngularModule, ChevronRight, ChevronDown, PanelLeftClose, PanelLeftOpen, LucideIconData } from 'lucide-angular';
|
||||
import { TreeItem, SidebarAction, BottomPanel, BottomPanelItem, LayoutService } from '../../services/layout.service';
|
||||
import { resolveIcon } from '../../lore/lore-icons';
|
||||
|
||||
@Component({
|
||||
selector: 'app-secondary-sidebar',
|
||||
standalone: true,
|
||||
imports: [CommonModule, LucideAngularModule],
|
||||
templateUrl: './secondary-sidebar.component.html',
|
||||
styleUrls: ['./secondary-sidebar.component.scss']
|
||||
})
|
||||
export class SecondarySidebarComponent {
|
||||
@Input() title = '';
|
||||
@Input() createActions: SidebarAction[] = [];
|
||||
@Input() bottomPanel: BottomPanel | null = null;
|
||||
@Output() collapsedChange = new EventEmitter<boolean>();
|
||||
|
||||
/** true = ouvert (on affiche les items) ; false = replié (titre seul). */
|
||||
panelOpen = true;
|
||||
|
||||
readonly ChevronDown = ChevronDown;
|
||||
readonly ChevronRight = ChevronRight;
|
||||
readonly PanelLeftClose = PanelLeftClose;
|
||||
readonly PanelLeftOpen = PanelLeftOpen;
|
||||
|
||||
isCollapsed = false;
|
||||
|
||||
private _items: TreeItem[] = [];
|
||||
|
||||
@Input() set items(value: TreeItem[]) {
|
||||
this._items = value ?? [];
|
||||
this.autoExpandActiveAncestors();
|
||||
}
|
||||
get items(): TreeItem[] { return this._items; }
|
||||
|
||||
constructor(private router: Router, private layoutService: LayoutService) {}
|
||||
|
||||
runAction(action: SidebarAction): void {
|
||||
if (action.route) { this.router.navigate([action.route]); }
|
||||
}
|
||||
|
||||
clickItem(item: TreeItem): void {
|
||||
if (item.route) { this.router.navigate([item.route]); return; }
|
||||
this.toggleItem(item.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clic sur le chevron : toggle uniquement (ne navigue jamais).
|
||||
* stopPropagation évite que l'event remonte au bouton parent.
|
||||
*/
|
||||
clickChevron(event: Event, item: TreeItem): void {
|
||||
event.stopPropagation();
|
||||
this.toggleItem(item.id);
|
||||
}
|
||||
|
||||
toggleCollapse(): void {
|
||||
this.isCollapsed = !this.isCollapsed;
|
||||
this.collapsedChange.emit(this.isCollapsed);
|
||||
}
|
||||
|
||||
toggleItem(id: string): void {
|
||||
this.layoutService.toggleExpanded(id);
|
||||
}
|
||||
|
||||
isExpanded(id: string): boolean {
|
||||
return this.layoutService.isExpanded(id);
|
||||
}
|
||||
|
||||
togglePanel(): void {
|
||||
this.panelOpen = !this.panelOpen;
|
||||
}
|
||||
|
||||
clickPanelItem(item: BottomPanelItem): void {
|
||||
if (item.route) { this.router.navigate([item.route]); }
|
||||
}
|
||||
|
||||
/** Résout la clé d'icône d'un TreeItem en icône lucide pour le template. */
|
||||
iconFor(item: TreeItem): LucideIconData | null {
|
||||
return item.iconKey ? resolveIcon(item.iconKey) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-déplie la chaîne d'ancêtres du item dont `route` matche l'URL active.
|
||||
* Nécessaire car la sidebar est détruite/recréée à chaque navigation (ngIf
|
||||
* dans app.component.html) : sans ça, même si on persiste `expandedItems`
|
||||
* dans le service, un deep-link sur une page profonde arriverait tout replié.
|
||||
*/
|
||||
private autoExpandActiveAncestors(): void {
|
||||
const url = this.router.url;
|
||||
// On descend d'abord dans les enfants pour trouver le match le plus profond :
|
||||
// sinon, un parent qui matche par préfixe (ex. /campaigns/A/arcs/X matche
|
||||
// aussi /campaigns/A/arcs/X/chapters/M) court-circuiterait la descente et
|
||||
// on ne déplierait pas l'arc pour montrer le chapitre actif.
|
||||
const walk = (item: TreeItem, ancestors: string[]): boolean => {
|
||||
if (item.children) {
|
||||
const nextAncestors = [...ancestors, item.id];
|
||||
for (const child of item.children) {
|
||||
if (walk(child, nextAncestors)) return true;
|
||||
}
|
||||
}
|
||||
const matches = !!item.route && (item.route === url || url.startsWith(item.route + '/'));
|
||||
if (matches) {
|
||||
ancestors.forEach(id => this.layoutService.setExpanded(id, true));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
for (const root of this._items) {
|
||||
walk(root, []);
|
||||
}
|
||||
}
|
||||
}
|
||||
66
web/src/app/sidebar/sidebar.component.html
Normal file
66
web/src/app/sidebar/sidebar.component.html
Normal file
@@ -0,0 +1,66 @@
|
||||
<aside class="sidebar">
|
||||
|
||||
<div class="sidebar-header">
|
||||
<div class="logo">
|
||||
<span class="logo-icon">✦</span>
|
||||
<span class="logo-text">LoreMind</span>
|
||||
</div>
|
||||
<p class="logo-subtitle">THE DIGITAL CODEX</p>
|
||||
</div>
|
||||
|
||||
<div class="nav-toggle">
|
||||
<button class="toggle-btn" [class.active]="currentRoute.startsWith('/lore')" (click)="navigateTo('/lore')">
|
||||
Lore
|
||||
</button>
|
||||
<button class="toggle-btn" [class.active]="currentRoute.startsWith('/campaigns')" (click)="navigateTo('/campaigns')">
|
||||
Campagne
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Mode contextuel : liste des lores ou campagnes -->
|
||||
<ng-container *ngIf="layoutConfig$ | async as config">
|
||||
<div class="context-list">
|
||||
<button
|
||||
class="context-item"
|
||||
*ngFor="let item of config.globalItems"
|
||||
[class.active]="isActive(item.route)"
|
||||
(click)="navigateTo(item.route)">
|
||||
{{ item.name }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="spacer"></div>
|
||||
|
||||
<button class="btn-back-all" (click)="navigateTo(config.globalBackRoute)">
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
|
||||
<span>{{ config.globalBackLabel }}</span>
|
||||
</button>
|
||||
</ng-container>
|
||||
|
||||
<!-- Mode normal : spacer + outils -->
|
||||
<ng-container *ngIf="!(layoutConfig$ | async)">
|
||||
<div class="spacer"></div>
|
||||
</ng-container>
|
||||
|
||||
<div class="tools-section">
|
||||
<p class="tools-label">OUTILS</p>
|
||||
<button class="tool-btn" (click)="openSearch()">
|
||||
<lucide-icon [img]="Search" [size]="16"></lucide-icon>
|
||||
<span>Recherche globale</span>
|
||||
<span class="tool-kbd">Ctrl+K</span>
|
||||
</button>
|
||||
<button class="tool-btn">
|
||||
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
|
||||
<span>Export VTT</span>
|
||||
</button>
|
||||
<button class="tool-btn">
|
||||
<lucide-icon [img]="Settings" [size]="16"></lucide-icon>
|
||||
<span>Paramètres</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">Version 1.0.0</span>
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
186
web/src/app/sidebar/sidebar.component.scss
Normal file
186
web/src/app/sidebar/sidebar.component.scss
Normal file
@@ -0,0 +1,186 @@
|
||||
.sidebar {
|
||||
width: 190px;
|
||||
height: 100vh;
|
||||
background: #0f0f1a;
|
||||
color: #e0e0e0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.5rem 1rem;
|
||||
border-right: 1px solid #1e1e3a;
|
||||
}
|
||||
|
||||
.sidebar-header { margin-bottom: 2rem; }
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.logo-icon { font-size: 1.2rem; color: #6c63ff; }
|
||||
.logo-text { font-size: 1.25rem; font-weight: 700; color: white; }
|
||||
|
||||
.logo-subtitle {
|
||||
font-size: 0.65rem;
|
||||
color: #6b7280;
|
||||
letter-spacing: 0.15em;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.nav-toggle {
|
||||
display: flex;
|
||||
background: #1a1a2e;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.25rem;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
flex: 1;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: transparent;
|
||||
color: #9ca3af;
|
||||
border: none;
|
||||
border-radius: 0.4rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover { color: white; }
|
||||
&.active { background: #6c63ff; color: white; }
|
||||
}
|
||||
|
||||
.spacer { flex: 1; }
|
||||
|
||||
.context-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
margin-top: 0.75rem;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.context-item {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #9ca3af;
|
||||
font-size: 0.82rem;
|
||||
padding: 0.45rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&:hover { background: #1f2937; color: white; }
|
||||
&.active { background: #1e1b4b; color: #a5b4fc; font-weight: 600; }
|
||||
}
|
||||
|
||||
// Bouton de retour vers la liste globale (ex: "Tous les lores" / "Toutes les campagnes").
|
||||
// Volontairement distinct des context-items pour signaler que c'est une action
|
||||
// de navigation hors-contexte (retour au niveau supérieur).
|
||||
.btn-back-all {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
background: #1f2937;
|
||||
border: 1px solid #374151;
|
||||
color: #d1d5db;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
padding: 0.6rem 0.85rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 0.75rem;
|
||||
// Séparateur visuel au-dessus pour détacher du bloc globalItems.
|
||||
margin-top: 0.75rem;
|
||||
position: relative;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s, transform 0.15s;
|
||||
|
||||
// Fine ligne de séparation au-dessus.
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -0.5rem;
|
||||
left: 0.5rem;
|
||||
right: 0.5rem;
|
||||
height: 1px;
|
||||
background: #1f2937;
|
||||
}
|
||||
|
||||
// Petit déplacement de la flèche au hover pour suggérer l'action "retour".
|
||||
lucide-icon {
|
||||
color: #9ca3af;
|
||||
transition: transform 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #374151;
|
||||
border-color: #6c63ff;
|
||||
color: white;
|
||||
|
||||
lucide-icon {
|
||||
color: #a5b4fc;
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tools-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.tools-label {
|
||||
font-size: 0.7rem;
|
||||
color: #6b7280;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.tool-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
background: transparent;
|
||||
color: #9ca3af;
|
||||
border: none;
|
||||
border-radius: 0.4rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover { color: white; }
|
||||
}
|
||||
|
||||
.tool-icon { font-size: 1rem; }
|
||||
|
||||
.tool-kbd {
|
||||
margin-left: auto;
|
||||
font-size: 0.7rem;
|
||||
background: #2d3145;
|
||||
color: #9ca3af;
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 3px;
|
||||
border: 1px solid #3a3f55;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #1e1e3a;
|
||||
}
|
||||
|
||||
.version { font-size: 0.7rem; color: #4b5563; }
|
||||
46
web/src/app/sidebar/sidebar.component.ts
Normal file
46
web/src/app/sidebar/sidebar.component.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { AsyncPipe, NgIf, NgFor } from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
import { LucideAngularModule, Search, Download, Settings, ArrowLeft } from 'lucide-angular';
|
||||
import { LayoutService } from '../services/layout.service';
|
||||
import { GlobalSearchService } from '../services/global-search.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-sidebar',
|
||||
standalone: true,
|
||||
imports: [AsyncPipe, NgIf, NgFor, LucideAngularModule],
|
||||
templateUrl: './sidebar.component.html',
|
||||
styleUrls: ['./sidebar.component.scss']
|
||||
})
|
||||
export class SidebarComponent {
|
||||
currentRoute = '';
|
||||
|
||||
readonly Search = Search;
|
||||
readonly Download = Download;
|
||||
readonly Settings = Settings;
|
||||
readonly ArrowLeft = ArrowLeft;
|
||||
|
||||
readonly layoutConfig$ = this.layoutService.secondarySidebar$;
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
private layoutService: LayoutService,
|
||||
private globalSearch: GlobalSearchService
|
||||
) {
|
||||
this.router.events.subscribe(() => {
|
||||
this.currentRoute = this.router.url;
|
||||
});
|
||||
}
|
||||
|
||||
navigateTo(route: string): void {
|
||||
this.router.navigate([route]);
|
||||
}
|
||||
|
||||
isActive(itemRoute: string): boolean {
|
||||
return this.currentRoute === itemRoute;
|
||||
}
|
||||
|
||||
openSearch(): void {
|
||||
this.globalSearch.open();
|
||||
}
|
||||
}
|
||||
13
web/src/index.html
Normal file
13
web/src/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>LoreMind</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
9
web/src/main.ts
Normal file
9
web/src/main.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { AppComponent } from './app/app.component';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { routes } from './app/app.routes';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
|
||||
bootstrapApplication(AppComponent, {
|
||||
providers: [provideRouter(routes), provideHttpClient()]
|
||||
}).catch((err: Error) => console.error(err));
|
||||
20
web/src/styles.scss
Normal file
20
web/src/styles.scss
Normal file
@@ -0,0 +1,20 @@
|
||||
// ==========================================================================
|
||||
// Feuille de style globale — point d'entrée unique pour les règles partagées
|
||||
// ==========================================================================
|
||||
// Les partials dans @app/../styles/ contiennent les patterns réutilisés par
|
||||
// plusieurs composants (boutons, champs de formulaire, etc.). Extraits ici
|
||||
// pour éliminer la duplication qui existait dans 16+ fichiers SCSS.
|
||||
@use './styles/buttons';
|
||||
@use './styles/forms';
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #0a0a14;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
75
web/src/styles/_buttons.scss
Normal file
75
web/src/styles/_buttons.scss
Normal file
@@ -0,0 +1,75 @@
|
||||
// ==========================================================================
|
||||
// Boutons partagés
|
||||
// --------------------------------------------------------------------------
|
||||
// Style canonique utilisé par TOUS les écrans "create" et "edit" (formulaires).
|
||||
// Extrait ici pour éliminer la duplication qui existait dans 16+ composants.
|
||||
//
|
||||
// Usage standard (formulaire plein pot) :
|
||||
// <button class="btn-primary">Sauvegarder</button>
|
||||
//
|
||||
// Variante compacte (headers avec juste 2 boutons alignés) :
|
||||
// <button class="btn-primary btn-sm">Modifier</button>
|
||||
//
|
||||
// Bouton avec icône Lucide :
|
||||
// <button class="btn-primary btn-icon">...</button>
|
||||
// ==========================================================================
|
||||
|
||||
.btn-primary {
|
||||
padding: 0.85rem 1.5rem;
|
||||
background: #6c63ff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover:not(:disabled) { background: #5b52e0; }
|
||||
&:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 0.85rem 1.5rem;
|
||||
background: #1f2937;
|
||||
color: #d1d5db;
|
||||
border: 1px solid #374151;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover:not(:disabled) { background: #374151; }
|
||||
&:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
padding: 0.85rem 1.25rem;
|
||||
background: transparent;
|
||||
color: #ef4444;
|
||||
border: 1px solid #7f1d1d;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
|
||||
&:hover:not(:disabled) { background: #7f1d1d; color: white; }
|
||||
&:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Modificateurs combinables
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// Variante compacte pour les barres d'actions de header (détail, edit inline).
|
||||
.btn-sm {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
// Alignement flex pour boutons qui contiennent un `<lucide-icon>` + texte.
|
||||
.btn-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
89
web/src/styles/_forms.scss
Normal file
89
web/src/styles/_forms.scss
Normal file
@@ -0,0 +1,89 @@
|
||||
// ==========================================================================
|
||||
// Champs de formulaire partagés
|
||||
// --------------------------------------------------------------------------
|
||||
// Style canonique pour tous les couples label + input / textarea / select.
|
||||
// Extrait ici pour éliminer la duplication dans 14+ composants.
|
||||
//
|
||||
// Usage :
|
||||
// <div class="field">
|
||||
// <label>Nom</label>
|
||||
// <input type="text" [(ngModel)]="name" />
|
||||
// </div>
|
||||
//
|
||||
// Astuce visuelle (texte gris italique sous un champ) :
|
||||
// <p class="field-hint">Optionnel. Facilite les recherches.</p>
|
||||
// ==========================================================================
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
label {
|
||||
font-size: 0.875rem;
|
||||
color: #d1d5db;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
background: #1f2937;
|
||||
border: 1px solid #374151;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1rem;
|
||||
color: white;
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&::placeholder { color: #4b5563; }
|
||||
&:focus { border-color: #6c63ff; }
|
||||
&.invalid, &.ng-invalid.ng-touched { border-color: #ef4444; }
|
||||
}
|
||||
|
||||
textarea { resize: vertical; min-height: 4rem; }
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
color: #6b7280;
|
||||
font-size: 0.75rem;
|
||||
font-style: italic;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
// Grille 2 colonnes pour aligner 2 champs côte à côte.
|
||||
.field-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Header de page "create" / "edit" (titre + éventuel sous-titre)
|
||||
// --------------------------------------------------------------------------
|
||||
.page-header {
|
||||
margin-bottom: 2rem;
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
margin: 0 0 0.35rem 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #6b7280;
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Barre d'actions en bas d'un formulaire (Sauvegarder / Annuler / Supprimer).
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding-top: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
Reference in New Issue
Block a user