Fix : problème d'ascenseur en bas de la page au niveau des templates

Sélection du template par défaut lors de la création d'une page en fonction du dossier
Passage v0.6.2
This commit is contained in:
2026-04-25 01:39:05 +02:00
parent d24d6459a0
commit a932144206
9 changed files with 132 additions and 25 deletions

View File

@@ -40,7 +40,7 @@ from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
app = FastAPI(
title="LoreMind Brain",
description="Backend IA pour la génération de contenu narratif.",
version="0.6.1",
version="0.6.2",
)

View File

@@ -14,7 +14,7 @@
<groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId>
<version>0.6.1</version>
<version>0.6.2</version>
<name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description>

View File

@@ -1,6 +1,6 @@
{
"name": "loremind-web",
"version": "0.6.1",
"version": "0.6.2",
"description": "LoreMind Frontend - Angular",
"scripts": {
"ng": "ng",

View File

@@ -87,7 +87,8 @@
&.selected {
border-color: #6c63ff;
background: #1e1c3a;
background: #2a2558;
box-shadow: 0 0 0 1px #6c63ff, 0 0 12px rgba(108, 99, 255, 0.35);
}
.template-card-head {

View File

@@ -94,8 +94,28 @@ export class PageCreateComponent implements OnInit, OnDestroy {
if (this.preselectedNodeId) {
this.form.patchValue({ nodeId: this.preselectedNodeId });
this.form.get('nodeId')?.disable();
this.autoSelectTemplateForNode(this.preselectedNodeId);
} else {
// Pas de nodeId dans l'URL : le <select> affiche visuellement la
// première option mais la valeur du FormControl reste ''. On tente
// l'auto-sélection inverse : si un seul template a un defaultNodeId
// qui pointe sur un dossier existant, on le sélectionne et on
// pré-remplit le dossier — sinon on laisse l'utilisateur choisir.
const validNodeIds = new Set(this.nodes.map(n => n.id));
const candidates = this.templates.filter(
t => t.defaultNodeId && validNodeIds.has(t.defaultNodeId)
);
if (candidates.length === 1) {
const tpl = candidates[0];
this.selectedTemplateId = tpl.id!;
this.form.patchValue({ nodeId: tpl.defaultNodeId });
}
}
this.form.get('nodeId')?.valueChanges.subscribe(nodeId => {
this.autoSelectTemplateForNode(nodeId);
});
this.restoreDraft();
});
}
@@ -137,6 +157,18 @@ export class PageCreateComponent implements OnInit, OnDestroy {
} catch { /* JSON corrompu : on ignore */ }
}
/**
* Auto-sélection du template dont defaultNodeId === nodeId courant.
* Ne fait rien si l'utilisateur a déjà choisi un template manuellement
* (on ne veut pas écraser un choix explicite).
*/
private autoSelectTemplateForNode(nodeId: string | null | undefined): void {
if (!nodeId) return;
if (this.selectedTemplateId) return;
const matching = this.templates.find(t => t.defaultNodeId === nodeId);
if (matching) this.selectedTemplateId = matching.id!;
}
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.

View File

@@ -1,6 +1,7 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { shareReplay, tap } from 'rxjs/operators';
import { Lore, LoreCreate, LoreNode, LoreNodeCreate } from './lore.model';
/** Compte des entités qui seront supprimées en cascade avec un dossier. */
@@ -23,6 +24,11 @@ export interface LoreDeletionImpact {
/**
* Service HTTP pour la gestion des Lores.
* Port de sortie vers le Backend Java (Architecture Hexagonale).
*
* Les lectures agrégées par la sidebar (getAllLores, getLoreById, getLoreNodes)
* sont mises en cache via `shareReplay(1)` pour éviter 5 fetchs redondants à
* chaque navigation interne. Toute mutation (create/update/delete) invalide
* l'ensemble du cache du service.
*/
@Injectable({
providedIn: 'root'
@@ -31,26 +37,51 @@ export class LoreService {
private apiUrl = '/api/lores';
private nodesUrl = '/api/lore-nodes';
private allLoresCache: Observable<Lore[]> | null = null;
private loreByIdCache = new Map<string, Observable<Lore>>();
private nodesByLoreIdCache = new Map<string, Observable<LoreNode[]>>();
constructor(private http: HttpClient) {}
/** Vide tous les caches de lecture — appelé après toute mutation. */
private invalidate(): void {
this.allLoresCache = null;
this.loreByIdCache.clear();
this.nodesByLoreIdCache.clear();
}
getAllLores(): Observable<Lore[]> {
return this.http.get<Lore[]>(this.apiUrl);
if (!this.allLoresCache) {
this.allLoresCache = this.http.get<Lore[]>(this.apiUrl).pipe(
tap({ error: () => (this.allLoresCache = null) }),
shareReplay(1)
);
}
return this.allLoresCache;
}
getLoreById(id: string): Observable<Lore> {
return this.http.get<Lore>(`${this.apiUrl}/${id}`);
let obs = this.loreByIdCache.get(id);
if (!obs) {
obs = this.http.get<Lore>(`${this.apiUrl}/${id}`).pipe(
tap({ error: () => this.loreByIdCache.delete(id) }),
shareReplay(1)
);
this.loreByIdCache.set(id, obs);
}
return obs;
}
createLore(lore: LoreCreate): Observable<Lore> {
return this.http.post<Lore>(this.apiUrl, lore);
return this.http.post<Lore>(this.apiUrl, lore).pipe(tap(() => this.invalidate()));
}
updateLore(id: string, lore: LoreCreate): Observable<Lore> {
return this.http.put<Lore>(`${this.apiUrl}/${id}`, lore);
return this.http.put<Lore>(`${this.apiUrl}/${id}`, lore).pipe(tap(() => this.invalidate()));
}
deleteLore(id: string): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
return this.http.delete<void>(`${this.apiUrl}/${id}`).pipe(tap(() => this.invalidate()));
}
getLoreDeletionImpact(id: string): Observable<LoreDeletionImpact> {
@@ -58,7 +89,15 @@ export class LoreService {
}
getLoreNodes(loreId: string): Observable<LoreNode[]> {
return this.http.get<LoreNode[]>(`${this.nodesUrl}?loreId=${loreId}`);
let obs = this.nodesByLoreIdCache.get(loreId);
if (!obs) {
obs = this.http.get<LoreNode[]>(`${this.nodesUrl}?loreId=${loreId}`).pipe(
tap({ error: () => this.nodesByLoreIdCache.delete(loreId) }),
shareReplay(1)
);
this.nodesByLoreIdCache.set(loreId, obs);
}
return obs;
}
getLoreNodeById(id: string): Observable<LoreNode> {
@@ -66,16 +105,16 @@ export class LoreService {
}
createLoreNode(node: LoreNodeCreate): Observable<LoreNode> {
return this.http.post<LoreNode>(this.nodesUrl, node);
return this.http.post<LoreNode>(this.nodesUrl, node).pipe(tap(() => this.invalidate()));
}
/** 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);
return this.http.put<LoreNode>(`${this.nodesUrl}/${id}`, node).pipe(tap(() => this.invalidate()));
}
deleteLoreNode(id: string): Observable<void> {
return this.http.delete<void>(`${this.nodesUrl}/${id}`);
return this.http.delete<void>(`${this.nodesUrl}/${id}`).pipe(tap(() => this.invalidate()));
}
getLoreNodeDeletionImpact(id: string): Observable<LoreNodeDeletionImpact> {

View File

@@ -1,23 +1,40 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { map, shareReplay, tap } from 'rxjs/operators';
import { Page, PageCreate } from './page.model';
/**
* Service HTTP pour la gestion des Pages.
* Port de sortie du Frontend vers le Backend Java (/api/pages).
*
* `getByLoreId` est cache via shareReplay(1) — toute mutation
* (create/update/delete) invalide l'ensemble du cache.
*/
@Injectable({ providedIn: 'root' })
export class PageService {
private apiUrl = '/api/pages';
private byLoreIdCache = new Map<string, Observable<Page[]>>();
constructor(private http: HttpClient) {}
private invalidate(): void {
this.byLoreIdCache.clear();
}
/** 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 });
let obs = this.byLoreIdCache.get(loreId);
if (!obs) {
const params = new HttpParams().set('loreId', loreId);
obs = this.http.get<Page[]>(this.apiUrl, { params }).pipe(
tap({ error: () => this.byLoreIdCache.delete(loreId) }),
shareReplay(1)
);
this.byLoreIdCache.set(loreId, obs);
}
return obs;
}
/** Toutes les pages d'un noeud donné. */
@@ -31,15 +48,15 @@ export class PageService {
}
create(payload: PageCreate): Observable<Page> {
return this.http.post<Page>(this.apiUrl, payload);
return this.http.post<Page>(this.apiUrl, payload).pipe(tap(() => this.invalidate()));
}
update(id: string, page: Page): Observable<Page> {
return this.http.put<Page>(`${this.apiUrl}/${id}`, page);
return this.http.put<Page>(`${this.apiUrl}/${id}`, page).pipe(tap(() => this.invalidate()));
}
delete(id: string): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
return this.http.delete<void>(`${this.apiUrl}/${id}`).pipe(tap(() => this.invalidate()));
}
search(q: string): Observable<Page[]> {

View File

@@ -1,22 +1,40 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { shareReplay, tap } from 'rxjs/operators';
import { Template, TemplateCreate } from './template.model';
/**
* Service HTTP pour la gestion des Templates.
* Port de sortie du Frontend vers le Backend Java (/api/templates).
*
* `getByLoreId` est cache via shareReplay(1) — toute mutation
* (create/update/delete) invalide l'ensemble du cache.
*/
@Injectable({ providedIn: 'root' })
export class TemplateService {
private apiUrl = '/api/templates';
private byLoreIdCache = new Map<string, Observable<Template[]>>();
constructor(private http: HttpClient) {}
private invalidate(): void {
this.byLoreIdCache.clear();
}
/** 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 });
let obs = this.byLoreIdCache.get(loreId);
if (!obs) {
const params = new HttpParams().set('loreId', loreId);
obs = this.http.get<Template[]>(this.apiUrl, { params }).pipe(
tap({ error: () => this.byLoreIdCache.delete(loreId) }),
shareReplay(1)
);
this.byLoreIdCache.set(loreId, obs);
}
return obs;
}
getById(id: string): Observable<Template> {
@@ -24,15 +42,15 @@ export class TemplateService {
}
create(payload: TemplateCreate): Observable<Template> {
return this.http.post<Template>(this.apiUrl, payload);
return this.http.post<Template>(this.apiUrl, payload).pipe(tap(() => this.invalidate()));
}
update(id: string, template: Template): Observable<Template> {
return this.http.put<Template>(`${this.apiUrl}/${id}`, template);
return this.http.put<Template>(`${this.apiUrl}/${id}`, template).pipe(tap(() => this.invalidate()));
}
delete(id: string): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
return this.http.delete<void>(`${this.apiUrl}/${id}`).pipe(tap(() => this.invalidate()));
}
search(q: string): Observable<Template[]> {

View File

@@ -7,7 +7,7 @@
flex-direction: column;
padding: 1.25rem 0.75rem;
gap: 0.75rem;
overflow-y: auto;
overflow: hidden;
position: relative;
// Pas de transition sur la largeur : sinon le drag de resize "traîne" derrière la souris.
// L'animation d'expand/collapse est gérée uniquement par la classe .collapsed ci-dessous.