Compare commits
4 Commits
51eb907078
...
v1.0.2-bet
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c8b24c5dd | |||
| 177967af55 | |||
| 42c4b53ced | |||
| 3e9e828225 |
@@ -82,9 +82,11 @@ jobs:
|
|||||||
if: failure()
|
if: failure()
|
||||||
run: docker compose -f docker-compose.yml -f docker-compose.e2e.yml logs --no-color
|
run: docker compose -f docker-compose.yml -f docker-compose.e2e.yml logs --no-color
|
||||||
|
|
||||||
|
# v3 obligatoire : l'API artifacts v4 n'est pas supportée par Gitea
|
||||||
|
# (GHESNotSupportedError — constaté sur le job MegaLinter).
|
||||||
- name: Upload Playwright report
|
- name: Upload Playwright report
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: playwright-report
|
name: playwright-report
|
||||||
path: web/playwright-report/
|
path: web/playwright-report/
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ name: Qualité & Sécurité
|
|||||||
# assainie, on pourra l'ajouter aux checks requis de la branch protection.
|
# assainie, on pourra l'ajouter aux checks requis de la branch protection.
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
# beta inclus : c'est la branche de dev, le feedback qualité doit y vivre
|
||||||
|
# (ci.yml/e2e.yml, eux, restent volontairement sur main + PR).
|
||||||
|
branches: [main, beta]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
@@ -27,11 +29,14 @@ jobs:
|
|||||||
- name: MegaLinter
|
- name: MegaLinter
|
||||||
uses: oxsecurity/megalinter/flavors/cupcake@v9
|
uses: oxsecurity/megalinter/flavors/cupcake@v9
|
||||||
env:
|
env:
|
||||||
# push sur main → tout le dépôt ; PR → seulement les fichiers modifiés.
|
# main → scan complet du dépôt ; beta et PR → seulement les fichiers
|
||||||
VALIDATE_ALL_CODEBASE: ${{ github.event_name == 'push' }}
|
# modifiés par rapport à main (rapide, feedback ciblé).
|
||||||
|
VALIDATE_ALL_CODEBASE: ${{ github.ref == 'refs/heads/main' }}
|
||||||
|
# v3 obligatoire : l'API artifacts v4 (@actions/artifact 2.x) n'est pas
|
||||||
|
# supportée par Gitea (GHESNotSupportedError constaté avec v4).
|
||||||
- name: Publier les rapports
|
- name: Publier les rapports
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: megalinter-reports
|
name: megalinter-reports
|
||||||
path: megalinter-reports/
|
path: megalinter-reports/
|
||||||
@@ -61,17 +66,39 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
# Pour résoudre pom.xml (versions héritées du parent Spring Boot), Trivy
|
||||||
|
# télécharge les BOMs depuis Maven Central — qui finit par rate-limiter
|
||||||
|
# l'IP du runner (429). Parade officielle : peupler ~/.m2 AVANT le scan,
|
||||||
|
# Trivy lit le cache local en priorité. Le cache setup-java (clé pom.xml)
|
||||||
|
# rend l'étape quasi gratuite d'un run à l'autre.
|
||||||
|
- uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: '17'
|
||||||
|
cache: maven
|
||||||
|
- name: Précharger le cache Maven (~/.m2)
|
||||||
|
working-directory: core
|
||||||
|
run: |
|
||||||
|
chmod +x ./mvnw
|
||||||
|
./mvnw -B -q dependency:go-offline
|
||||||
- name: Installer Trivy
|
- name: Installer Trivy
|
||||||
run: curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
|
run: curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
|
||||||
# Scanne les manifestes du dépôt (pom.xml, requirements*.txt,
|
# Scanne les manifestes du dépôt (pom.xml, requirements*.txt,
|
||||||
# package-lock.json) contre les bases CVE. --ignore-unfixed : on ne
|
# package-lock.json) contre les bases CVE. --ignore-unfixed : on ne
|
||||||
# bloque que sur les vulnérabilités qui ONT un correctif publié.
|
# bloque que sur les vulnérabilités qui ONT un correctif publié.
|
||||||
|
# --offline-scan : AUCUNE requête vers Maven Central pendant la
|
||||||
|
# résolution — Trivy allait y chercher les POMs parents même avec le
|
||||||
|
# cache rempli, et l'IP du runner finissait rate-limitée (429, 30 min).
|
||||||
|
# Tout est déjà dans ~/.m2 grâce au dependency:go-offline ci-dessus ;
|
||||||
|
# contrepartie : une dépendance absente du cache serait ignorée en
|
||||||
|
# silence (impossible ici, go-offline échouerait d'abord).
|
||||||
- name: Scan des dépendances (HIGH/CRITICAL bloquants)
|
- name: Scan des dépendances (HIGH/CRITICAL bloquants)
|
||||||
run: |
|
run: |
|
||||||
trivy fs . \
|
trivy fs . \
|
||||||
--scanners vuln \
|
--scanners vuln \
|
||||||
--severity HIGH,CRITICAL \
|
--severity HIGH,CRITICAL \
|
||||||
--ignore-unfixed \
|
--ignore-unfixed \
|
||||||
|
--offline-scan \
|
||||||
--skip-dirs node_modules \
|
--skip-dirs node_modules \
|
||||||
--skip-dirs docusaurus \
|
--skip-dirs docusaurus \
|
||||||
--exit-code 1
|
--exit-code 1
|
||||||
|
|||||||
@@ -35,3 +35,9 @@ REPORT_OUTPUT_FOLDER: megalinter-reports
|
|||||||
# Phase de rodage : passer temporairement à true pour rendre le job
|
# Phase de rodage : passer temporairement à true pour rendre le job
|
||||||
# informatif (rapport sans échec CI) le temps de purger l'existant.
|
# informatif (rapport sans échec CI) le temps de purger l'existant.
|
||||||
DISABLE_ERRORS: false
|
DISABLE_ERRORS: false
|
||||||
|
|
||||||
|
# PMD : ruleset projet à la racine (java-pmd-ruleset.xml, détecté
|
||||||
|
# automatiquement) — orienté bugs réels, sans style. Non-bloquant le temps
|
||||||
|
# de purger le backlog (~65 findings : PreserveStackTrace, EmptyCatchBlock,
|
||||||
|
# CheckResultSet, RelianceOnDefaultCharset…). Repasser à false ensuite.
|
||||||
|
JAVA_PMD_DISABLE_ERRORS: true
|
||||||
|
|||||||
13
core/pom.xml
13
core/pom.xml
@@ -24,6 +24,19 @@
|
|||||||
>= 3.18 : corrige CVE-2025-48924 (recursion infinie ClassUtils.getClass).
|
>= 3.18 : corrige CVE-2025-48924 (recursion infinie ClassUtils.getClass).
|
||||||
Propriete reconnue par le BOM Spring Boot → s'applique partout. -->
|
Propriete reconnue par le BOM Spring Boot → s'applique partout. -->
|
||||||
<commons-lang3.version>3.20.0</commons-lang3.version>
|
<commons-lang3.version>3.20.0</commons-lang3.version>
|
||||||
|
<!-- Overrides CVE (detectes par Trivy en CI — job quality.yml) :
|
||||||
|
proprietes reconnues par le BOM Spring Boot, comme ci-dessus. -->
|
||||||
|
<!-- >= 2.21.4 : CVE-2026-54512 / CVE-2026-54513 (execution de code
|
||||||
|
arbitraire via contournement du PolymorphicTypeValidator). -->
|
||||||
|
<jackson-bom.version>2.21.4</jackson-bom.version>
|
||||||
|
<!-- >= 4.1.135 : lot de CVE netty (DoS codec/handler, bypass de
|
||||||
|
verification hostname CVE-2026-50010, DNS CVE-2026-45674/47691). -->
|
||||||
|
<netty.version>4.1.135.Final</netty.version>
|
||||||
|
<!-- >= 10.1.55 : 3 CRITICAL Tomcat (CVE-2026-41293 headers HTTP/2 non
|
||||||
|
valides, CVE-2026-43512 bypass auth digest, CVE-2026-43515). -->
|
||||||
|
<tomcat.version>10.1.55</tomcat.version>
|
||||||
|
<!-- >= 42.7.11 : CVE-2026-42198 (DoS client via SCRAM-SHA-256). -->
|
||||||
|
<postgresql.version>42.7.11</postgresql.version>
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
|
|||||||
73
java-pmd-ruleset.xml
Normal file
73
java-pmd-ruleset.xml
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<ruleset xmlns="http://pmd.sourceforge.net/ruleset/2.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
name="LoreMind"
|
||||||
|
xsi:schemaLocation="http://pmd.sourceforge.net/ruleset/2.0.0 https://pmd.sourceforge.io/ruleset_2_0_0.xsd">
|
||||||
|
|
||||||
|
<description>
|
||||||
|
Ruleset PMD LoreMind — détecté automatiquement par MegaLinter (JAVA_PMD).
|
||||||
|
Philosophie : bugs et pièges réels uniquement. Les catégories de STYLE
|
||||||
|
(codestyle/design/documentation) sont exclues : le défaut MegaLinter y
|
||||||
|
générait ~11 000 violations hors sujet (conventions incompatibles avec
|
||||||
|
l'idiome Lombok/slf4j/Spring du projet : champ `log`, DI constructeur…).
|
||||||
|
</description>
|
||||||
|
|
||||||
|
<!-- Bugs probables : null, equals/hashCode, ressources, casts douteux… -->
|
||||||
|
<rule ref="category/java/errorprone.xml">
|
||||||
|
<!-- Chaque littéral dupliqué = une violation : du bruit, pas un bug. -->
|
||||||
|
<exclude name="AvoidDuplicateLiterals"/>
|
||||||
|
<exclude name="AvoidLiteralsInIfCondition"/>
|
||||||
|
<!-- Entités JPA/DTO : pas de sérialisation Java native ici. -->
|
||||||
|
<exclude name="MissingSerialVersionUID"/>
|
||||||
|
<!-- `this.name = name` en DI/setters : idiome standard. -->
|
||||||
|
<exclude name="AvoidFieldNameMatchingMethodName"/>
|
||||||
|
<exclude name="AvoidFieldNameMatchingTypeName"/>
|
||||||
|
<!-- `while ((x = read()) != null)` : idiome de lecture standard. -->
|
||||||
|
<exclude name="AssignmentInOperand"/>
|
||||||
|
<!-- catch(Exception) best-effort : choix assumé du projet (imports,
|
||||||
|
exports, best-effort serveur) — pas un gate. -->
|
||||||
|
<exclude name="AvoidCatchingGenericException"/>
|
||||||
|
<!-- TODO backlog : réactiver et passer les toLowerCase/UpperCase en
|
||||||
|
Locale.ROOT (27 sites) — bug potentiel (locale turque) mais
|
||||||
|
changement de comportement à valider posément. -->
|
||||||
|
<exclude name="UseLocaleWithCaseConversions"/>
|
||||||
|
</rule>
|
||||||
|
|
||||||
|
<!-- Concurrence -->
|
||||||
|
<rule ref="category/java/multithreading.xml">
|
||||||
|
<!-- Règle d'époque J2EE : les threads sont légitimes (sidecar, SSE…). -->
|
||||||
|
<exclude name="DoNotUseThreads"/>
|
||||||
|
<!-- Flaguerait CHAQUE HashMap, y compris locales à une méthode (×135). -->
|
||||||
|
<exclude name="UseConcurrentHashMap"/>
|
||||||
|
</rule>
|
||||||
|
|
||||||
|
<!-- Performance (sans les micro-optimisations StringBuilder : le rendu
|
||||||
|
PDF/HTML fait des milliers d'appends, aucun n'est un point chaud). -->
|
||||||
|
<rule ref="category/java/performance.xml">
|
||||||
|
<exclude name="AvoidInstantiatingObjectsInLoops"/>
|
||||||
|
<exclude name="AppendCharacterWithChar"/>
|
||||||
|
<exclude name="ConsecutiveLiteralAppends"/>
|
||||||
|
<exclude name="ConsecutiveAppendsShouldReuse"/>
|
||||||
|
<exclude name="InsufficientStringBufferDeclaration"/>
|
||||||
|
</rule>
|
||||||
|
|
||||||
|
<!-- Sécurité -->
|
||||||
|
<rule ref="category/java/security.xml"/>
|
||||||
|
|
||||||
|
<!-- Bonnes pratiques (sous-ensemble non-style) -->
|
||||||
|
<rule ref="category/java/bestpractices.xml">
|
||||||
|
<!-- slf4j paramétré ({} + args) rend le guard inutile. -->
|
||||||
|
<exclude name="GuardLogStatement"/>
|
||||||
|
<!-- Les ports hexagonaux à méthode unique ne sont pas des lambdas. -->
|
||||||
|
<exclude name="ImplicitFunctionalInterface"/>
|
||||||
|
<!-- Déclarer List vs ArrayList : style, pas un bug. -->
|
||||||
|
<exclude name="LooseCoupling"/>
|
||||||
|
<!-- StringBuilder en champ : pattern builder assumé (export PDF). -->
|
||||||
|
<exclude name="AvoidStringBufferField"/>
|
||||||
|
<!-- Style de tests : à l'appréciation du projet, pas un gate. -->
|
||||||
|
<exclude name="UnitTestAssertionsShouldIncludeMessage"/>
|
||||||
|
<exclude name="UnitTestContainsTooManyAsserts"/>
|
||||||
|
<exclude name="UnitTestShouldIncludeAssert"/>
|
||||||
|
</rule>
|
||||||
|
|
||||||
|
</ruleset>
|
||||||
@@ -42,6 +42,23 @@ module.exports = defineConfig([
|
|||||||
// Math.random() est légitime ici : tables aléatoires / jets de dés = le
|
// Math.random() est légitime ici : tables aléatoires / jets de dés = le
|
||||||
// domaine métier. Aucun usage cryptographique côté front.
|
// domaine métier. Aucun usage cryptographique côté front.
|
||||||
"sonarjs/pseudo-random": "off",
|
"sonarjs/pseudo-random": "off",
|
||||||
|
// 9 fonctions historiques dépassent le seuil de 15 (jusqu'à 39 sur les
|
||||||
|
// graphes campagne/chapitre). Les refactorer "pour le lint" sans filet de
|
||||||
|
// tests serait plus risqué qu'utile → warn (visible, non bloquant).
|
||||||
|
// Backlog : campaign-graph (×3), chapter-graph (×2), campaign-import,
|
||||||
|
// settings, persona-view, sse.util. Repasser en "error" une fois résorbé.
|
||||||
|
"sonarjs/cognitive-complexity": "warn",
|
||||||
|
// Convention TS standard : un préfixe `_` marque un paramètre/variable
|
||||||
|
// volontairement inutilisé (ex. paramètres conservés pour ne pas casser
|
||||||
|
// les appelants — cf. campaign-tree.helper.ts).
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
argsIgnorePattern: "^_",
|
||||||
|
varsIgnorePattern: "^_",
|
||||||
|
caughtErrorsIgnorePattern: "^_",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
1693
web/package-lock.json
generated
1693
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -20,15 +20,15 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^21.2.16",
|
"@angular/animations": "^21.2.17",
|
||||||
"@angular/cdk": "^21.2.14",
|
"@angular/cdk": "^21.2.14",
|
||||||
"@angular/common": "^21.2.16",
|
"@angular/common": "^21.2.17",
|
||||||
"@angular/compiler": "^21.2.16",
|
"@angular/compiler": "^21.2.17",
|
||||||
"@angular/core": "^21.2.16",
|
"@angular/core": "^21.2.17",
|
||||||
"@angular/forms": "^21.2.16",
|
"@angular/forms": "^21.2.17",
|
||||||
"@angular/platform-browser": "^21.2.16",
|
"@angular/platform-browser": "^21.2.17",
|
||||||
"@angular/platform-browser-dynamic": "^21.2.16",
|
"@angular/platform-browser-dynamic": "^21.2.17",
|
||||||
"@angular/router": "^21.2.16",
|
"@angular/router": "^21.2.17",
|
||||||
"@ngx-translate/core": "^18.0.0",
|
"@ngx-translate/core": "^18.0.0",
|
||||||
"@ngx-translate/http-loader": "^18.0.0",
|
"@ngx-translate/http-loader": "^18.0.0",
|
||||||
"dompurify": "^3.4.1",
|
"dompurify": "^3.4.1",
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-devkit/build-angular": "^21.2.14",
|
"@angular-devkit/build-angular": "^21.2.14",
|
||||||
"@angular/cli": "^21.2.14",
|
"@angular/cli": "^21.2.14",
|
||||||
"@angular/compiler-cli": "^21.2.16",
|
"@angular/compiler-cli": "^21.2.17",
|
||||||
"@emnapi/core": "^1.11.0",
|
"@emnapi/core": "^1.11.0",
|
||||||
"@emnapi/runtime": "^1.11.0",
|
"@emnapi/runtime": "^1.11.0",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
|
||||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
@@ -25,7 +25,7 @@ import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
|||||||
templateUrl: './arc-create.component.html',
|
templateUrl: './arc-create.component.html',
|
||||||
styleUrls: ['./arc-create.component.scss']
|
styleUrls: ['./arc-create.component.scss']
|
||||||
})
|
})
|
||||||
export class ArcCreateComponent implements OnInit, OnDestroy {
|
export class ArcCreateComponent implements OnInit {
|
||||||
readonly BookOpen = BookOpen;
|
readonly BookOpen = BookOpen;
|
||||||
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
|
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
|
||||||
|
|
||||||
@@ -88,11 +88,4 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
|
|||||||
cancel(): void {
|
cancel(): void {
|
||||||
this.router.navigate(['/campaigns', this.campaignId]);
|
this.router.navigate(['/campaigns', this.campaignId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
// Volontairement vide : la sidebar reste prise en charge par le composant
|
|
||||||
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
|
||||||
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
|
||||||
// disparition de la sidebar lors des navigations internes a la section.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -183,5 +183,5 @@
|
|||||||
[isOpen]="chatOpen"
|
[isOpen]="chatOpen"
|
||||||
[welcomeMessage]="'arcEdit.chatWelcome' | translate"
|
[welcomeMessage]="'arcEdit.chatWelcome' | translate"
|
||||||
[quickSuggestions]="chatQuickSuggestions"
|
[quickSuggestions]="chatQuickSuggestions"
|
||||||
(close)="chatOpen = false">
|
(closed)="chatOpen = false">
|
||||||
</app-ai-chat-drawer>
|
</app-ai-chat-drawer>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
|
import { Component, OnInit, DestroyRef } from '@angular/core';
|
||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
|
|
||||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
@@ -42,7 +42,7 @@ import { FieldProposal } from '../../../services/entity-assist.model';
|
|||||||
templateUrl: './arc-edit.component.html',
|
templateUrl: './arc-edit.component.html',
|
||||||
styleUrls: ['./arc-edit.component.scss']
|
styleUrls: ['./arc-edit.component.scss']
|
||||||
})
|
})
|
||||||
export class ArcEditComponent implements OnInit, OnDestroy {
|
export class ArcEditComponent implements OnInit {
|
||||||
readonly Trash2 = Trash2;
|
readonly Trash2 = Trash2;
|
||||||
readonly Sparkles = Sparkles;
|
readonly Sparkles = Sparkles;
|
||||||
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
|
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
|
||||||
@@ -221,11 +221,4 @@ export class ArcEditComponent implements OnInit, OnDestroy {
|
|||||||
cancel(): void {
|
cancel(): void {
|
||||||
this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId]);
|
this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
// Volontairement vide : la sidebar reste prise en charge par le composant
|
|
||||||
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
|
||||||
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
|
||||||
// disparition de la sidebar lors des navigations internes a la section.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
|
import { Component, OnInit, DestroyRef } from '@angular/core';
|
||||||
|
|
||||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||||
import { forkJoin, of } from 'rxjs';
|
import { forkJoin, of } from 'rxjs';
|
||||||
@@ -35,7 +35,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
|
|||||||
templateUrl: './arc-view.component.html',
|
templateUrl: './arc-view.component.html',
|
||||||
styleUrls: ['./arc-view.component.scss']
|
styleUrls: ['./arc-view.component.scss']
|
||||||
})
|
})
|
||||||
export class ArcViewComponent implements OnInit, OnDestroy {
|
export class ArcViewComponent implements OnInit {
|
||||||
readonly Pencil = Pencil;
|
readonly Pencil = Pencil;
|
||||||
readonly Trash2 = Trash2;
|
readonly Trash2 = Trash2;
|
||||||
readonly Plus = Plus;
|
readonly Plus = Plus;
|
||||||
@@ -162,8 +162,4 @@ export class ArcViewComponent implements OnInit, OnDestroy {
|
|||||||
error: () => console.error('Impossible de récupérer les dépendances de l\'arc')
|
error: () => console.error('Impossible de récupérer les dépendances de l\'arc')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
// Volontairement vide : la sidebar reste prise en charge par le composant suivant.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export class CampaignGraphComponent implements OnInit, OnDestroy {
|
|||||||
private readonly DRAG_THRESHOLD = 4;
|
private readonly DRAG_THRESHOLD = 4;
|
||||||
|
|
||||||
// Adjacence (ids de nœuds) — sert à recalculer les arêtes après un drag.
|
// Adjacence (ids de nœuds) — sert à recalculer les arêtes après un drag.
|
||||||
private adjacency: Array<{ key: string; kind: NodeKind; a: string; b: string }> = [];
|
private adjacency: { key: string; kind: NodeKind; a: string; b: string }[] = [];
|
||||||
|
|
||||||
// Disposition personnalisée : positions sauvegardées sur la campagne, sauvegarde
|
// Disposition personnalisée : positions sauvegardées sur la campagne, sauvegarde
|
||||||
// différée après un drag (évite un PUT par pixel déplacé).
|
// différée après un drag (évite un PUT par pixel déplacé).
|
||||||
@@ -174,7 +174,10 @@ export class CampaignGraphComponent implements OnInit, OnDestroy {
|
|||||||
if (this.draggingId) return; // pas de changement de focus en plein drag
|
if (this.draggingId) return; // pas de changement de focus en plein drag
|
||||||
this.hoveredId = node.id;
|
this.hoveredId = node.id;
|
||||||
this.hoverNeighbors = new Set(
|
this.hoverNeighbors = new Set(
|
||||||
this.adjacency.flatMap(e => e.a === node.id ? [e.b] : e.b === node.id ? [e.a] : []));
|
this.adjacency.flatMap(e => {
|
||||||
|
if (e.a === node.id) return [e.b];
|
||||||
|
return e.b === node.id ? [e.a] : [];
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
onNodeLeave(): void {
|
onNodeLeave(): void {
|
||||||
@@ -230,7 +233,7 @@ export class CampaignGraphComponent implements OnInit, OnDestroy {
|
|||||||
for (const [arcId, chapters] of Object.entries(treeData.chaptersByArc)) {
|
for (const [arcId, chapters] of Object.entries(treeData.chaptersByArc)) {
|
||||||
for (const ch of chapters) arcOfChapter.set(ch.id!, arcId);
|
for (const ch of chapters) arcOfChapter.set(ch.id!, arcId);
|
||||||
}
|
}
|
||||||
const linkedScenes: Array<{ scene: Scene; chapterId: string }> = [];
|
const linkedScenes: { scene: Scene; chapterId: string }[] = [];
|
||||||
if (!this.hiddenKinds.has('scene')) {
|
if (!this.hiddenKinds.has('scene')) {
|
||||||
for (const [chapterId, scenes] of Object.entries(treeData.scenesByChapter)) {
|
for (const [chapterId, scenes] of Object.entries(treeData.scenesByChapter)) {
|
||||||
if (!arcOfChapter.has(chapterId)) continue;
|
if (!arcOfChapter.has(chapterId)) continue;
|
||||||
@@ -268,7 +271,7 @@ export class CampaignGraphComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
// Arêtes. Les liens page↔page sont dé-dupliqués par paire non-orientée
|
// Arêtes. Les liens page↔page sont dé-dupliqués par paire non-orientée
|
||||||
// (A→B et B→A = un seul trait) ; les liens entité→page portent le type de l'entité.
|
// (A→B et B→A = un seul trait) ; les liens entité→page portent le type de l'entité.
|
||||||
const adjacency: Array<{ key: string; kind: NodeKind; a: string; b: string }> = [];
|
const adjacency: { key: string; kind: NodeKind; a: string; b: string }[] = [];
|
||||||
const seenPairs = new Set<string>();
|
const seenPairs = new Set<string>();
|
||||||
for (const p of pages) {
|
for (const p of pages) {
|
||||||
for (const targetId of p.relatedPageIds ?? []) {
|
for (const targetId of p.relatedPageIds ?? []) {
|
||||||
@@ -530,14 +533,16 @@ export class CampaignGraphComponent implements OnInit, OnDestroy {
|
|||||||
if (Math.abs(dx) >= needX || Math.abs(dy) >= needY) continue;
|
if (Math.abs(dx) >= needX || Math.abs(dy) >= needY) continue;
|
||||||
const overlapX = needX - Math.abs(dx);
|
const overlapX = needX - Math.abs(dx);
|
||||||
const overlapY = needY - Math.abs(dy);
|
const overlapY = needY - Math.abs(dy);
|
||||||
|
// Départage déterministe quand les centres coïncident (dx/dy = 0).
|
||||||
|
const tieBreak = i % 2 === 0 ? 1 : -1;
|
||||||
if (overlapX / needX < overlapY / needY) {
|
if (overlapX / needX < overlapY / needY) {
|
||||||
const shift = overlapX / 2 + 0.5;
|
const shift = overlapX / 2 + 0.5;
|
||||||
const sign = dx !== 0 ? Math.sign(dx) : (i % 2 === 0 ? 1 : -1);
|
const sign = dx !== 0 ? Math.sign(dx) : tieBreak;
|
||||||
a.x -= sign * shift;
|
a.x -= sign * shift;
|
||||||
b.x += sign * shift;
|
b.x += sign * shift;
|
||||||
} else {
|
} else {
|
||||||
const shift = overlapY / 2 + 0.5;
|
const shift = overlapY / 2 + 0.5;
|
||||||
const sign = dy !== 0 ? Math.sign(dy) : (i % 2 === 0 ? 1 : -1);
|
const sign = dy !== 0 ? Math.sign(dy) : tieBreak;
|
||||||
a.y -= sign * shift;
|
a.y -= sign * shift;
|
||||||
b.y += sign * shift;
|
b.y += sign * shift;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,7 +82,10 @@ export function buildCampaignTree(
|
|||||||
// Clé TYPE|ID obligatoire : chaque table a sa propre séquence IDENTITY (une quête
|
// Clé TYPE|ID obligatoire : chaque table a sa propre séquence IDENTITY (une quête
|
||||||
// id 6 et une scène id 6 coexistent) — sans le type, un manque de quête allumerait
|
// id 6 et une scène id 6 coexistent) — sans le type, un manque de quête allumerait
|
||||||
// à tort la pastille d'une scène/PNJ portant le même numéro.
|
// à tort la pastille d'une scène/PNJ portant le même numéro.
|
||||||
const sevRank = (s: ReadinessSeverity): number => (s === 'BLOCKING' ? 2 : s === 'RECOMMENDED' ? 1 : 0);
|
const sevRank = (s: ReadinessSeverity): number => {
|
||||||
|
if (s === 'BLOCKING') return 2;
|
||||||
|
return s === 'RECOMMENDED' ? 1 : 0;
|
||||||
|
};
|
||||||
const gapsByKey = new Map<string, ReadinessGap[]>();
|
const gapsByKey = new Map<string, ReadinessGap[]>();
|
||||||
for (const g of readinessGaps) {
|
for (const g of readinessGaps) {
|
||||||
const key = `${g.entityType}|${g.entityId}`;
|
const key = `${g.entityType}|${g.entityId}`;
|
||||||
|
|||||||
@@ -69,7 +69,7 @@
|
|||||||
[placeholder]="'campaignCreate.gameSystemNamePlaceholder' | translate"
|
[placeholder]="'campaignCreate.gameSystemNamePlaceholder' | translate"
|
||||||
(keydown.enter)="$event.preventDefault(); submitCreateGameSystem()"
|
(keydown.enter)="$event.preventDefault(); submitCreateGameSystem()"
|
||||||
(keydown.escape)="cancelCreateGameSystem()"
|
(keydown.escape)="cancelCreateGameSystem()"
|
||||||
autofocus
|
|
||||||
/>
|
/>
|
||||||
<div class="inline-create-actions">
|
<div class="inline-create-actions">
|
||||||
<button type="button" class="btn-inline-primary"
|
<button type="button" class="btn-inline-primary"
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export interface CampaignCreatePayload {
|
|||||||
styleUrls: ['./campaign-create.component.scss']
|
styleUrls: ['./campaign-create.component.scss']
|
||||||
})
|
})
|
||||||
export class CampaignCreateComponent implements OnInit {
|
export class CampaignCreateComponent implements OnInit {
|
||||||
@Output() close = new EventEmitter<void>();
|
@Output() closed = new EventEmitter<void>();
|
||||||
@Output() created = new EventEmitter<CampaignCreatePayload>();
|
@Output() created = new EventEmitter<CampaignCreatePayload>();
|
||||||
|
|
||||||
readonly BookCopy = BookCopy;
|
readonly BookCopy = BookCopy;
|
||||||
@@ -131,6 +131,6 @@ export class CampaignCreateComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onCancel(): void {
|
onCancel(): void {
|
||||||
this.close.emit();
|
this.closed.emit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,7 +106,7 @@
|
|||||||
[placeholder]="'campaignDetail.gameSystemNamePlaceholder' | translate"
|
[placeholder]="'campaignDetail.gameSystemNamePlaceholder' | translate"
|
||||||
(keydown.enter)="$event.preventDefault(); submitCreateGameSystem()"
|
(keydown.enter)="$event.preventDefault(); submitCreateGameSystem()"
|
||||||
(keydown.escape)="cancelCreateGameSystem()"
|
(keydown.escape)="cancelCreateGameSystem()"
|
||||||
autofocus
|
|
||||||
/>
|
/>
|
||||||
<div class="inline-create-actions">
|
<div class="inline-create-actions">
|
||||||
<button type="button" class="btn-inline-primary"
|
<button type="button" class="btn-inline-primary"
|
||||||
|
|||||||
@@ -52,6 +52,19 @@ interface NpcNode { name: string; description: string; selected: boolean; existi
|
|||||||
* Flux : upload → progression streamée → arbre éditable (revue) → création.
|
* Flux : upload → progression streamée → arbre éditable (revue) → création.
|
||||||
* Rien n'est créé tant que l'utilisateur n'a pas validé « Créer dans la campagne ».
|
* Rien n'est créé tant que l'utilisateur n'a pas validé « Créer dans la campagne ».
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/** Nettoie les pièces (rooms) d'une scène pour le payload de création. */
|
||||||
|
function cleanRooms(rooms: { name: string; description: string; enemies: string; loot: string }[]) {
|
||||||
|
return rooms
|
||||||
|
.filter(r => r.name.trim())
|
||||||
|
.map(r => ({
|
||||||
|
name: r.name.trim(),
|
||||||
|
description: r.description.trim(),
|
||||||
|
enemies: r.enemies.trim(),
|
||||||
|
loot: r.loot.trim()
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-campaign-import',
|
selector: 'app-campaign-import',
|
||||||
imports: [FormsModule, LucideAngularModule, TranslatePipe, FileDropDirective],
|
imports: [FormsModule, LucideAngularModule, TranslatePipe, FileDropDirective],
|
||||||
@@ -401,14 +414,7 @@ export class CampaignImportComponent implements OnInit {
|
|||||||
playerNarration: s.playerNarration.trim(),
|
playerNarration: s.playerNarration.trim(),
|
||||||
gmNotes: s.gmNotes.trim(),
|
gmNotes: s.gmNotes.trim(),
|
||||||
existingId: s.existingId ?? null,
|
existingId: s.existingId ?? null,
|
||||||
rooms: s.rooms
|
rooms: cleanRooms(s.rooms)
|
||||||
.filter(r => r.name.trim())
|
|
||||||
.map(r => ({
|
|
||||||
name: r.name.trim(),
|
|
||||||
description: r.description.trim(),
|
|
||||||
enemies: r.enemies.trim(),
|
|
||||||
loot: r.loot.trim()
|
|
||||||
}))
|
|
||||||
}))
|
}))
|
||||||
}))
|
}))
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
|
|
||||||
@if (showCreateModal) {
|
@if (showCreateModal) {
|
||||||
<app-campaign-create
|
<app-campaign-create
|
||||||
(close)="onModalClose()"
|
(closed)="onModalClose()"
|
||||||
(created)="onCampaignCreated($event)">
|
(created)="onCampaignCreated($event)">
|
||||||
</app-campaign-create>
|
</app-campaign-create>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
|
||||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
@@ -24,7 +24,7 @@ import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
|||||||
templateUrl: './chapter-create.component.html',
|
templateUrl: './chapter-create.component.html',
|
||||||
styleUrls: ['./chapter-create.component.scss']
|
styleUrls: ['./chapter-create.component.scss']
|
||||||
})
|
})
|
||||||
export class ChapterCreateComponent implements OnInit, OnDestroy {
|
export class ChapterCreateComponent implements OnInit {
|
||||||
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
|
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
|
||||||
selectedIcon: string | null = null;
|
selectedIcon: string | null = null;
|
||||||
|
|
||||||
@@ -88,11 +88,4 @@ export class ChapterCreateComponent implements OnInit, OnDestroy {
|
|||||||
cancel(): void {
|
cancel(): void {
|
||||||
this.router.navigate(['/campaigns', this.campaignId]);
|
this.router.navigate(['/campaigns', this.campaignId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
// Volontairement vide : la sidebar reste prise en charge par le composant
|
|
||||||
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
|
||||||
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
|
||||||
// disparition de la sidebar lors des navigations internes a la section.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,5 +151,5 @@
|
|||||||
[isOpen]="chatOpen"
|
[isOpen]="chatOpen"
|
||||||
[welcomeMessage]="'chapterEdit.chatWelcome' | translate"
|
[welcomeMessage]="'chapterEdit.chatWelcome' | translate"
|
||||||
[quickSuggestions]="chatQuickSuggestions"
|
[quickSuggestions]="chatQuickSuggestions"
|
||||||
(close)="chatOpen = false">
|
(closed)="chatOpen = false">
|
||||||
</app-ai-chat-drawer>
|
</app-ai-chat-drawer>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
|
import { Component, OnInit, DestroyRef } from '@angular/core';
|
||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
|
|
||||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
@@ -52,7 +52,7 @@ import { SceneDraftPanelComponent } from '../../../shared/scene-draft-panel/scen
|
|||||||
templateUrl: './chapter-edit.component.html',
|
templateUrl: './chapter-edit.component.html',
|
||||||
styleUrls: ['./chapter-edit.component.scss']
|
styleUrls: ['./chapter-edit.component.scss']
|
||||||
})
|
})
|
||||||
export class ChapterEditComponent implements OnInit, OnDestroy {
|
export class ChapterEditComponent implements OnInit {
|
||||||
readonly Trash2 = Trash2;
|
readonly Trash2 = Trash2;
|
||||||
readonly Sparkles = Sparkles;
|
readonly Sparkles = Sparkles;
|
||||||
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
|
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
|
||||||
@@ -220,6 +220,4 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
|||||||
cancel(): void {
|
cancel(): void {
|
||||||
this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId]);
|
this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, OnInit, OnDestroy, ElementRef, ViewChild, HostListener, DestroyRef } from '@angular/core';
|
import { Component, OnInit, ElementRef, ViewChild, HostListener, DestroyRef } from '@angular/core';
|
||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
|
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
@@ -29,7 +29,7 @@ interface GraphEdge { key: string; label: string; kind: LinkType; x1: number; y1
|
|||||||
templateUrl: './chapter-graph.component.html',
|
templateUrl: './chapter-graph.component.html',
|
||||||
styleUrls: ['./chapter-graph.component.scss']
|
styleUrls: ['./chapter-graph.component.scss']
|
||||||
})
|
})
|
||||||
export class ChapterGraphComponent implements OnInit, OnDestroy {
|
export class ChapterGraphComponent implements OnInit {
|
||||||
readonly ArrowLeft = ArrowLeft;
|
readonly ArrowLeft = ArrowLeft;
|
||||||
readonly Plus = Plus;
|
readonly Plus = Plus;
|
||||||
readonly ZoomIn = ZoomIn;
|
readonly ZoomIn = ZoomIn;
|
||||||
@@ -507,7 +507,7 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
|
|||||||
scene.graphX = node.x;
|
scene.graphX = node.x;
|
||||||
scene.graphY = node.y;
|
scene.graphY = node.y;
|
||||||
this.campaignService.updateScene(nodeId, { ...scene, order: scene.order ?? 0 })
|
this.campaignService.updateScene(nodeId, { ...scene, order: scene.order ?? 0 })
|
||||||
.subscribe({ error: () => {} });
|
.subscribe({ error: () => { /* best-effort : erreur ignorée volontairement */ } });
|
||||||
}
|
}
|
||||||
|
|
||||||
private truncate(text: string): string {
|
private truncate(text: string): string {
|
||||||
@@ -548,7 +548,7 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
|
|||||||
};
|
};
|
||||||
this.campaignService.createScene(payload).subscribe({
|
this.campaignService.createScene(payload).subscribe({
|
||||||
next: () => this.load(),
|
next: () => this.load(),
|
||||||
error: () => {}
|
error: () => { /* best-effort : erreur ignorée volontairement */ }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -592,7 +592,7 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
|
|||||||
node.name = name;
|
node.name = name;
|
||||||
node.displayName = this.truncate(name);
|
node.displayName = this.truncate(name);
|
||||||
scene.name = name;
|
scene.name = name;
|
||||||
this.campaignService.updateScene(id, { ...scene, order: scene.order ?? 0 }).subscribe({ error: () => {} });
|
this.campaignService.updateScene(id, { ...scene, order: scene.order ?? 0 }).subscribe({ error: () => { /* best-effort : erreur ignorée volontairement */ } });
|
||||||
}
|
}
|
||||||
|
|
||||||
cancelRename(): void {
|
cancelRename(): void {
|
||||||
@@ -635,7 +635,7 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
|
|||||||
if (existing.some(b => b.targetSceneId === toId)) return;
|
if (existing.some(b => b.targetSceneId === toId)) return;
|
||||||
const next: SceneBranch[] = [...existing, { label: '', targetSceneId: toId, condition: '', kind: 'EXIT' }];
|
const next: SceneBranch[] = [...existing, { label: '', targetSceneId: toId, condition: '', kind: 'EXIT' }];
|
||||||
this.campaignService.updateScene(fromId, { ...scene, order: scene.order ?? 0, branches: next })
|
this.campaignService.updateScene(fromId, { ...scene, order: scene.order ?? 0, branches: next })
|
||||||
.subscribe({ next: () => this.load(), error: () => {} });
|
.subscribe({ next: () => this.load(), error: () => { /* best-effort : erreur ignorée volontairement */ } });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────── Sélection / édition / suppression d'un lien ───────────────
|
// ─────────────── Sélection / édition / suppression d'un lien ───────────────
|
||||||
@@ -698,7 +698,7 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
|
|||||||
? { ...b, label: this.editEdgeLabel.trim(), condition: this.editEdgeCondition.trim(), kind: this.editEdgeKind }
|
? { ...b, label: this.editEdgeLabel.trim(), condition: this.editEdgeCondition.trim(), kind: this.editEdgeKind }
|
||||||
: b);
|
: b);
|
||||||
this.campaignService.updateScene(scene.id!, { ...scene, order: scene.order ?? 0, branches })
|
this.campaignService.updateScene(scene.id!, { ...scene, order: scene.order ?? 0, branches })
|
||||||
.subscribe({ next: () => { this.selectedEdgeKey = null; this.load(); }, error: () => {} });
|
.subscribe({ next: () => { this.selectedEdgeKey = null; this.load(); }, error: () => { /* best-effort : erreur ignorée volontairement */ } });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Supprime (sépare) le lien sélectionné. */
|
/** Supprime (sépare) le lien sélectionné. */
|
||||||
@@ -709,10 +709,6 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
|
|||||||
const { scene, branchIndex } = found;
|
const { scene, branchIndex } = found;
|
||||||
const branches = (scene.branches ?? []).filter((_, i) => i !== branchIndex);
|
const branches = (scene.branches ?? []).filter((_, i) => i !== branchIndex);
|
||||||
this.campaignService.updateScene(scene.id!, { ...scene, order: scene.order ?? 0, branches })
|
this.campaignService.updateScene(scene.id!, { ...scene, order: scene.order ?? 0, branches })
|
||||||
.subscribe({ next: () => { this.selectedEdgeKey = null; this.load(); }, error: () => {} });
|
.subscribe({ next: () => { this.selectedEdgeKey = null; this.load(); }, error: () => { /* best-effort : erreur ignorée volontairement */ } });
|
||||||
}
|
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
// Volontairement vide : la sidebar reste prise en charge par le composant suivant.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
|
import { Component, OnInit, DestroyRef } from '@angular/core';
|
||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
|
|
||||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||||
@@ -30,7 +30,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
|
|||||||
templateUrl: './chapter-view.component.html',
|
templateUrl: './chapter-view.component.html',
|
||||||
styleUrls: ['./chapter-view.component.scss']
|
styleUrls: ['./chapter-view.component.scss']
|
||||||
})
|
})
|
||||||
export class ChapterViewComponent implements OnInit, OnDestroy {
|
export class ChapterViewComponent implements OnInit {
|
||||||
readonly Pencil = Pencil;
|
readonly Pencil = Pencil;
|
||||||
readonly Network = Network;
|
readonly Network = Network;
|
||||||
readonly Trash2 = Trash2;
|
readonly Trash2 = Trash2;
|
||||||
@@ -147,11 +147,4 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
|
|||||||
error: () => console.error('Impossible de récupérer les dépendances du chapitre')
|
error: () => console.error('Impossible de récupérer les dépendances du chapitre')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
// Volontairement vide : la sidebar reste prise en charge par le composant
|
|
||||||
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
|
||||||
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
|
||||||
// disparition de la sidebar lors des navigations internes a la section.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,6 +100,6 @@
|
|||||||
[isOpen]="chatOpen"
|
[isOpen]="chatOpen"
|
||||||
[welcomeMessage]="'characterEdit.chatWelcome' | translate"
|
[welcomeMessage]="'characterEdit.chatWelcome' | translate"
|
||||||
[quickSuggestions]="chatQuickSuggestions"
|
[quickSuggestions]="chatQuickSuggestions"
|
||||||
(close)="chatOpen = false">
|
(closed)="chatOpen = false">
|
||||||
</app-ai-chat-drawer>
|
</app-ai-chat-drawer>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,6 @@
|
|||||||
entityType="character"
|
entityType="character"
|
||||||
[entityId]="characterId"
|
[entityId]="characterId"
|
||||||
[isOpen]="chatOpen"
|
[isOpen]="chatOpen"
|
||||||
(close)="chatOpen = false">
|
(closed)="chatOpen = false">
|
||||||
</app-ai-chat-drawer>
|
</app-ai-chat-drawer>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,11 @@ export class ItemCatalogViewComponent implements OnInit {
|
|||||||
map.get(cat)!.push(it);
|
map.get(cat)!.push(it);
|
||||||
}
|
}
|
||||||
return [...map.entries()]
|
return [...map.entries()]
|
||||||
.sort(([a], [b]) => (a === '—' ? 1 : b === '—' ? -1 : a.localeCompare(b, 'fr')))
|
.sort(([a], [b]) => {
|
||||||
|
if (a === '—') return 1; // catégorie "sans catégorie" toujours en dernier
|
||||||
|
if (b === '—') return -1;
|
||||||
|
return a.localeCompare(b, 'fr');
|
||||||
|
})
|
||||||
.map(([category, items]) => ({ category, items }));
|
.map(([category, items]) => ({ category, items }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -131,6 +131,6 @@
|
|||||||
[isOpen]="chatOpen"
|
[isOpen]="chatOpen"
|
||||||
[welcomeMessage]="'npcEdit.chatWelcome' | translate"
|
[welcomeMessage]="'npcEdit.chatWelcome' | translate"
|
||||||
[quickSuggestions]="chatQuickSuggestions"
|
[quickSuggestions]="chatQuickSuggestions"
|
||||||
(close)="chatOpen = false">
|
(closed)="chatOpen = false">
|
||||||
</app-ai-chat-drawer>
|
</app-ai-chat-drawer>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,6 @@
|
|||||||
entityType="npc"
|
entityType="npc"
|
||||||
[entityId]="npcId"
|
[entityId]="npcId"
|
||||||
[isOpen]="chatOpen"
|
[isOpen]="chatOpen"
|
||||||
(close)="chatOpen = false">
|
(closed)="chatOpen = false">
|
||||||
</app-ai-chat-drawer>
|
</app-ai-chat-drawer>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ import { Page } from '../../../services/page.model';
|
|||||||
import { PersonaViewComponent } from '../../../shared/persona-view/persona-view.component';
|
import { PersonaViewComponent } from '../../../shared/persona-view/persona-view.component';
|
||||||
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||||
|
|
||||||
|
/** Indexe des pages par id (les pages venant du serveur ont toujours un id). */
|
||||||
|
function indexPagesById(pages: Page[]): Map<string, Page> {
|
||||||
|
return new Map(pages.map(p => [p.id!, p]));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vue lecture seule "WorldAnvil" d'une fiche PNJ.
|
* Vue lecture seule "WorldAnvil" d'une fiche PNJ.
|
||||||
* Route : /campaigns/:campaignId/npcs/:npcId
|
* Route : /campaigns/:campaignId/npcs/:npcId
|
||||||
@@ -89,7 +94,7 @@ export class NpcViewComponent implements OnInit, OnDestroy {
|
|||||||
if (camp.loreId) {
|
if (camp.loreId) {
|
||||||
this.loreId = camp.loreId;
|
this.loreId = camp.loreId;
|
||||||
this.pageService.getByLoreId(camp.loreId).subscribe(pages => {
|
this.pageService.getByLoreId(camp.loreId).subscribe(pages => {
|
||||||
this.lorePagesById = new Map(pages.map(p => [p.id!, p]));
|
this.lorePagesById = indexPagesById(pages);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
|
import { Component, OnInit, DestroyRef } from '@angular/core';
|
||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
|
|
||||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||||
@@ -34,7 +34,7 @@ import { SessionPrepPanelComponent } from '../../../shared/session-prep-panel/se
|
|||||||
templateUrl: './playthrough-detail.component.html',
|
templateUrl: './playthrough-detail.component.html',
|
||||||
styleUrls: ['./playthrough-detail.component.scss']
|
styleUrls: ['./playthrough-detail.component.scss']
|
||||||
})
|
})
|
||||||
export class PlaythroughDetailComponent implements OnInit, OnDestroy {
|
export class PlaythroughDetailComponent implements OnInit {
|
||||||
readonly ArrowLeft = ArrowLeft;
|
readonly ArrowLeft = ArrowLeft;
|
||||||
readonly Play = Play;
|
readonly Play = Play;
|
||||||
readonly Flag = Flag;
|
readonly Flag = Flag;
|
||||||
@@ -155,6 +155,4 @@ export class PlaythroughDetailComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
|
import { Component, OnInit, DestroyRef } from '@angular/core';
|
||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
|
|
||||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||||
@@ -26,7 +26,7 @@ import { PlaythroughFlagsManagerComponent } from '../../../shared/playthrough-fl
|
|||||||
templateUrl: './playthrough-flags-page.component.html',
|
templateUrl: './playthrough-flags-page.component.html',
|
||||||
styleUrls: ['./playthrough-flags-page.component.scss']
|
styleUrls: ['./playthrough-flags-page.component.scss']
|
||||||
})
|
})
|
||||||
export class PlaythroughFlagsPageComponent implements OnInit, OnDestroy {
|
export class PlaythroughFlagsPageComponent implements OnInit {
|
||||||
readonly ArrowLeft = ArrowLeft;
|
readonly ArrowLeft = ArrowLeft;
|
||||||
|
|
||||||
campaignId = '';
|
campaignId = '';
|
||||||
@@ -75,6 +75,4 @@ export class PlaythroughFlagsPageComponent implements OnInit, OnDestroy {
|
|||||||
back(): void {
|
back(): void {
|
||||||
this.router.navigate(['/campaigns', this.campaignId]);
|
this.router.navigate(['/campaigns', this.campaignId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
|
import { Component, OnInit, DestroyRef } from '@angular/core';
|
||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
@@ -41,7 +41,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
|
|||||||
templateUrl: './quest-edit.component.html',
|
templateUrl: './quest-edit.component.html',
|
||||||
styleUrls: ['./quest-edit.component.scss']
|
styleUrls: ['./quest-edit.component.scss']
|
||||||
})
|
})
|
||||||
export class QuestEditComponent implements OnInit, OnDestroy {
|
export class QuestEditComponent implements OnInit {
|
||||||
readonly Trash2 = Trash2;
|
readonly Trash2 = Trash2;
|
||||||
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
|
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
|
||||||
selectedIcon: string | null = null;
|
selectedIcon: string | null = null;
|
||||||
@@ -232,6 +232,4 @@ export class QuestEditComponent implements OnInit, OnDestroy {
|
|||||||
cancel(): void {
|
cancel(): void {
|
||||||
this.router.navigate(['/campaigns', this.campaignId, 'quests']);
|
this.router.navigate(['/campaigns', this.campaignId, 'quests']);
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
|
import { Component, OnInit, DestroyRef } from '@angular/core';
|
||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||||
import { forkJoin, of } from 'rxjs';
|
import { forkJoin, of } from 'rxjs';
|
||||||
@@ -33,7 +33,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
|
|||||||
templateUrl: './quest-view.component.html',
|
templateUrl: './quest-view.component.html',
|
||||||
styleUrls: ['./quest-view.component.scss']
|
styleUrls: ['./quest-view.component.scss']
|
||||||
})
|
})
|
||||||
export class QuestViewComponent implements OnInit, OnDestroy {
|
export class QuestViewComponent implements OnInit {
|
||||||
readonly Pencil = Pencil;
|
readonly Pencil = Pencil;
|
||||||
readonly Trash2 = Trash2;
|
readonly Trash2 = Trash2;
|
||||||
readonly BookOpen = BookOpen;
|
readonly BookOpen = BookOpen;
|
||||||
@@ -199,8 +199,4 @@ export class QuestViewComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
// Volontairement vide : la sidebar reste prise en charge par le composant suivant.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
|
||||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
@@ -24,7 +24,7 @@ import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
|||||||
templateUrl: './scene-create.component.html',
|
templateUrl: './scene-create.component.html',
|
||||||
styleUrls: ['./scene-create.component.scss']
|
styleUrls: ['./scene-create.component.scss']
|
||||||
})
|
})
|
||||||
export class SceneCreateComponent implements OnInit, OnDestroy {
|
export class SceneCreateComponent implements OnInit {
|
||||||
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
|
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
|
||||||
selectedIcon: string | null = null;
|
selectedIcon: string | null = null;
|
||||||
|
|
||||||
@@ -90,11 +90,4 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
|
|||||||
cancel(): void {
|
cancel(): void {
|
||||||
this.router.navigate(['/campaigns', this.campaignId]);
|
this.router.navigate(['/campaigns', this.campaignId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
// Volontairement vide : la sidebar reste prise en charge par le composant
|
|
||||||
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
|
||||||
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
|
||||||
// disparition de la sidebar lors des navigations internes a la section.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -385,5 +385,5 @@
|
|||||||
[isOpen]="chatOpen"
|
[isOpen]="chatOpen"
|
||||||
[welcomeMessage]="'sceneEdit.chatWelcome' | translate"
|
[welcomeMessage]="'sceneEdit.chatWelcome' | translate"
|
||||||
[quickSuggestions]="chatQuickSuggestions"
|
[quickSuggestions]="chatQuickSuggestions"
|
||||||
(close)="chatOpen = false">
|
(closed)="chatOpen = false">
|
||||||
</app-ai-chat-drawer>
|
</app-ai-chat-drawer>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
|
import { Component, OnInit, DestroyRef } from '@angular/core';
|
||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
|
|
||||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
@@ -67,7 +67,7 @@ interface BattlemapEdit {
|
|||||||
templateUrl: './scene-edit.component.html',
|
templateUrl: './scene-edit.component.html',
|
||||||
styleUrls: ['./scene-edit.component.scss']
|
styleUrls: ['./scene-edit.component.scss']
|
||||||
})
|
})
|
||||||
export class SceneEditComponent implements OnInit, OnDestroy {
|
export class SceneEditComponent implements OnInit {
|
||||||
readonly Trash2 = Trash2;
|
readonly Trash2 = Trash2;
|
||||||
readonly Sparkles = Sparkles;
|
readonly Sparkles = Sparkles;
|
||||||
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
|
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
|
||||||
@@ -251,7 +251,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
|||||||
};
|
};
|
||||||
if (entry.mediaFileId) {
|
if (entry.mediaFileId) {
|
||||||
this.storedFileService.getById(entry.mediaFileId)
|
this.storedFileService.getById(entry.mediaFileId)
|
||||||
.subscribe({ next: f => entry.mediaName = f.filename, error: () => {} });
|
.subscribe({ next: f => entry.mediaName = f.filename, error: () => { /* best-effort : erreur ignorée volontairement */ } });
|
||||||
}
|
}
|
||||||
if (entry.dataFileId) {
|
if (entry.dataFileId) {
|
||||||
this.storedFileService.getById(entry.dataFileId).subscribe({
|
this.storedFileService.getById(entry.dataFileId).subscribe({
|
||||||
@@ -260,7 +260,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
|||||||
// Deduit la source : un .dd2vtt/.uvtt => DungeonDraft.
|
// Deduit la source : un .dd2vtt/.uvtt => DungeonDraft.
|
||||||
if (/\.(dd2vtt|uvtt)$/i.test(f.filename)) entry.source = 'DUNGEONDRAFT';
|
if (/\.(dd2vtt|uvtt)$/i.test(f.filename)) entry.source = 'DUNGEONDRAFT';
|
||||||
},
|
},
|
||||||
error: () => {}
|
error: () => { /* best-effort : erreur ignorée volontairement */ }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return entry;
|
return entry;
|
||||||
@@ -480,12 +480,14 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
|||||||
onDd2vttSelected(bm: BattlemapEdit, event: Event): void {
|
onDd2vttSelected(bm: BattlemapEdit, event: Event): void {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
const file = input.files?.[0];
|
const file = input.files?.[0];
|
||||||
if (file) void this.uploadDd2vtt(bm, file);
|
// Fire-and-forget : uploadDd2vtt gère lui-même ses erreurs.
|
||||||
|
if (file) this.uploadDd2vtt(bm, file);
|
||||||
input.value = '';
|
input.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
onDd2vttDropped(bm: BattlemapEdit, files: File[]): void {
|
onDd2vttDropped(bm: BattlemapEdit, files: File[]): void {
|
||||||
if (files[0]) void this.uploadDd2vtt(bm, files[0]);
|
// Fire-and-forget : uploadDd2vtt gère lui-même ses erreurs.
|
||||||
|
if (files[0]) this.uploadDd2vtt(bm, files[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -552,13 +554,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private extForType(type: string): string {
|
private extForType(type: string): string {
|
||||||
return type === 'image/jpeg' ? '.jpg' : type === 'image/webp' ? '.webp' : '.png';
|
if (type === 'image/jpeg') return '.jpg';
|
||||||
}
|
return type === 'image/webp' ? '.webp' : '.png';
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
// Volontairement vide : la sidebar reste prise en charge par le composant
|
|
||||||
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
|
||||||
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
|
||||||
// disparition de la sidebar lors des navigations internes a la section.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
|
import { Component, OnInit, DestroyRef } from '@angular/core';
|
||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
|
|
||||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||||
@@ -31,7 +31,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
|
|||||||
templateUrl: './scene-view.component.html',
|
templateUrl: './scene-view.component.html',
|
||||||
styleUrls: ['./scene-view.component.scss']
|
styleUrls: ['./scene-view.component.scss']
|
||||||
})
|
})
|
||||||
export class SceneViewComponent implements OnInit, OnDestroy {
|
export class SceneViewComponent implements OnInit {
|
||||||
readonly Pencil = Pencil;
|
readonly Pencil = Pencil;
|
||||||
readonly Trash2 = Trash2;
|
readonly Trash2 = Trash2;
|
||||||
readonly resolveCampaignIcon = resolveCampaignIcon;
|
readonly resolveCampaignIcon = resolveCampaignIcon;
|
||||||
@@ -160,11 +160,4 @@ export class SceneViewComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
// Volontairement vide : la sidebar reste prise en charge par le composant
|
|
||||||
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
|
||||||
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
|
||||||
// disparition de la sidebar lors des navigations internes a la section.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -361,7 +361,10 @@ export class GameSystemEditComponent implements OnInit {
|
|||||||
};
|
};
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const match = line.match(/^##\s+(.+?)\s*$/);
|
// `\S` force un début de titre non-blanc : frontière déterministe entre
|
||||||
|
// `\s+` et la capture (l'ancien `.+?`+`\s*$` backtrackait). Le trim()
|
||||||
|
// en aval retire les espaces de fin.
|
||||||
|
const match = line.match(/^##\s+(\S.*)$/);
|
||||||
if (match) {
|
if (match) {
|
||||||
flush();
|
flush();
|
||||||
current = { title: match[1].trim(), content: '', collapsed: false };
|
current = { title: match[1].trim(), content: '', collapsed: false };
|
||||||
|
|||||||
@@ -154,10 +154,14 @@ export class BlockGridBuilderComponent implements OnDestroy {
|
|||||||
|
|
||||||
addLabel(block: TemplateField): void { block.labels = [...(block.labels ?? []), '']; this.emit(); }
|
addLabel(block: TemplateField): void { block.labels = [...(block.labels ?? []), '']; this.emit(); }
|
||||||
updateLabel(block: TemplateField, i: number, value: string): void {
|
updateLabel(block: TemplateField, i: number, value: string): void {
|
||||||
if (!block.labels) return; block.labels[i] = value; this.emit();
|
if (!block.labels) return;
|
||||||
|
block.labels[i] = value;
|
||||||
|
this.emit();
|
||||||
}
|
}
|
||||||
removeLabel(block: TemplateField, i: number): void {
|
removeLabel(block: TemplateField, i: number): void {
|
||||||
if (!block.labels) return; block.labels = block.labels.filter((_, k) => k !== i); this.emit();
|
if (!block.labels) return;
|
||||||
|
block.labels = block.labels.filter((_, k) => k !== i);
|
||||||
|
this.emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Glisser : création / déplacement / redimensionnement ---------------
|
// --- Glisser : création / déplacement / redimensionnement ---------------
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, OnDestroy, OnInit, DestroyRef } from '@angular/core';
|
import { Component, OnInit, DestroyRef } from '@angular/core';
|
||||||
|
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { forkJoin } from 'rxjs';
|
import { forkJoin } from 'rxjs';
|
||||||
@@ -33,7 +33,7 @@ import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog
|
|||||||
templateUrl: './folder-view.component.html',
|
templateUrl: './folder-view.component.html',
|
||||||
styleUrls: ['./folder-view.component.scss']
|
styleUrls: ['./folder-view.component.scss']
|
||||||
})
|
})
|
||||||
export class FolderViewComponent implements OnInit, OnDestroy {
|
export class FolderViewComponent implements OnInit {
|
||||||
readonly Folder = Folder;
|
readonly Folder = Folder;
|
||||||
readonly FileText = FileText;
|
readonly FileText = FileText;
|
||||||
readonly Pencil = Pencil;
|
readonly Pencil = Pencil;
|
||||||
@@ -237,11 +237,4 @@ export class FolderViewComponent implements OnInit, OnDestroy {
|
|||||||
error: () => console.error('Impossible de récupérer les dépendances du dossier')
|
error: () => console.error('Impossible de récupérer les dépendances du dossier')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
// Volontairement vide : la sidebar reste prise en charge par le composant
|
|
||||||
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
|
||||||
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
|
||||||
// disparition de la sidebar lors des navigations internes a la section.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { TranslatePipe } from '@ngx-translate/core';
|
|||||||
styleUrls: ['./lore-create.component.scss']
|
styleUrls: ['./lore-create.component.scss']
|
||||||
})
|
})
|
||||||
export class LoreCreateComponent {
|
export class LoreCreateComponent {
|
||||||
@Output() close = new EventEmitter<void>();
|
@Output() closed = new EventEmitter<void>();
|
||||||
@Output() created = new EventEmitter<{ name: string; description: string }>();
|
@Output() created = new EventEmitter<{ name: string; description: string }>();
|
||||||
|
|
||||||
readonly BookCopy = BookCopy;
|
readonly BookCopy = BookCopy;
|
||||||
@@ -32,6 +32,6 @@ export class LoreCreateComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onCancel(): void {
|
onCancel(): void {
|
||||||
this.close.emit();
|
this.closed.emit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
|
||||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
@@ -11,7 +11,7 @@ import { LayoutService } from '../../services/layout.service';
|
|||||||
import { LoreNode } from '../../services/lore.model';
|
import { LoreNode } from '../../services/lore.model';
|
||||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||||
import { popReturnTo } from '../return-stack.helper';
|
import { popReturnTo } from '../return-stack.helper';
|
||||||
import { LORE_ICON_OPTIONS, IconOption, resolveIcon } from '../lore-icons';
|
import { LORE_ICON_OPTIONS, IconOption } from '../lore-icons';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-lore-node-create',
|
selector: 'app-lore-node-create',
|
||||||
@@ -19,7 +19,7 @@ import { LORE_ICON_OPTIONS, IconOption, resolveIcon } from '../lore-icons';
|
|||||||
templateUrl: './lore-node-create.component.html',
|
templateUrl: './lore-node-create.component.html',
|
||||||
styleUrls: ['./lore-node-create.component.scss']
|
styleUrls: ['./lore-node-create.component.scss']
|
||||||
})
|
})
|
||||||
export class LoreNodeCreateComponent implements OnInit, OnDestroy {
|
export class LoreNodeCreateComponent implements OnInit {
|
||||||
|
|
||||||
readonly iconOptions: IconOption[] = LORE_ICON_OPTIONS;
|
readonly iconOptions: IconOption[] = LORE_ICON_OPTIONS;
|
||||||
|
|
||||||
@@ -109,11 +109,4 @@ export class LoreNodeCreateComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
this.router.navigate(['/lore', this.loreId]);
|
this.router.navigate(['/lore', this.loreId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
// Volontairement vide : la sidebar reste prise en charge par le composant
|
|
||||||
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
|
||||||
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
|
||||||
// disparition de la sidebar lors des navigations internes a la section.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
|
import { Component, OnInit, DestroyRef } from '@angular/core';
|
||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
|
|
||||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
@@ -38,7 +38,7 @@ import { LORE_ICON_OPTIONS, IconOption } from '../lore-icons';
|
|||||||
templateUrl: './lore-node-edit.component.html',
|
templateUrl: './lore-node-edit.component.html',
|
||||||
styleUrls: ['./lore-node-edit.component.scss']
|
styleUrls: ['./lore-node-edit.component.scss']
|
||||||
})
|
})
|
||||||
export class LoreNodeEditComponent implements OnInit, OnDestroy {
|
export class LoreNodeEditComponent implements OnInit {
|
||||||
|
|
||||||
readonly iconOptions: IconOption[] = LORE_ICON_OPTIONS;
|
readonly iconOptions: IconOption[] = LORE_ICON_OPTIONS;
|
||||||
|
|
||||||
@@ -147,11 +147,4 @@ export class LoreNodeEditComponent implements OnInit, OnDestroy {
|
|||||||
if (!key) return null;
|
if (!key) return null;
|
||||||
return this.iconOptions.find(o => o.key === key)?.icon ?? null;
|
return this.iconOptions.find(o => o.key === key)?.icon ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
// Volontairement vide : la sidebar reste prise en charge par le composant
|
|
||||||
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
|
||||||
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
|
||||||
// disparition de la sidebar lors des navigations internes a la section.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
|
|
||||||
@if (showCreateModal) {
|
@if (showCreateModal) {
|
||||||
<app-lore-create
|
<app-lore-create
|
||||||
(close)="onModalClose()"
|
(closed)="onModalClose()"
|
||||||
(created)="onLoreCreated($event)">
|
(created)="onLoreCreated($event)">
|
||||||
</app-lore-create>
|
</app-lore-create>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,7 +111,7 @@
|
|||||||
[systemPromptAddon]="wizardSystemPrompt"
|
[systemPromptAddon]="wizardSystemPrompt"
|
||||||
[quickSuggestions]="wizardSuggestions"
|
[quickSuggestions]="wizardSuggestions"
|
||||||
[primaryAction]="wizardPrimaryAction"
|
[primaryAction]="wizardPrimaryAction"
|
||||||
(close)="closeWizard()"
|
(closed)="closeWizard()"
|
||||||
(assistantReply)="onWizardReply($event)"
|
(assistantReply)="onWizardReply($event)"
|
||||||
(primaryActionClick)="applyWizardAndCreate()">
|
(primaryActionClick)="applyWizardAndCreate()">
|
||||||
</app-ai-chat-drawer>
|
</app-ai-chat-drawer>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
|
import { Component, OnInit, DestroyRef } from '@angular/core';
|
||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
|
|
||||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
@@ -32,7 +32,7 @@ import { AiChatDrawerComponent, ChatPrimaryAction } from '../../shared/ai-chat-d
|
|||||||
templateUrl: './page-create.component.html',
|
templateUrl: './page-create.component.html',
|
||||||
styleUrls: ['./page-create.component.scss']
|
styleUrls: ['./page-create.component.scss']
|
||||||
})
|
})
|
||||||
export class PageCreateComponent implements OnInit, OnDestroy {
|
export class PageCreateComponent implements OnInit {
|
||||||
readonly FileText = FileText;
|
readonly FileText = FileText;
|
||||||
readonly Sparkles = Sparkles;
|
readonly Sparkles = Sparkles;
|
||||||
readonly Plus = Plus;
|
readonly Plus = Plus;
|
||||||
@@ -165,7 +165,7 @@ export class PageCreateComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private restoreDraft(): void {
|
private restoreDraft(): void {
|
||||||
let raw: string | null = null;
|
let raw: string | null;
|
||||||
try { raw = sessionStorage.getItem(this.draftKey); } catch { return; }
|
try { raw = sessionStorage.getItem(this.draftKey); } catch { return; }
|
||||||
if (!raw) return;
|
if (!raw) return;
|
||||||
sessionStorage.removeItem(this.draftKey);
|
sessionStorage.removeItem(this.draftKey);
|
||||||
@@ -328,7 +328,9 @@ Les clés du JSON doivent correspondre EXACTEMENT aux noms de champs indiqués.
|
|||||||
* Retourne null si absent ou JSON invalide.
|
* Retourne null si absent ou JSON invalide.
|
||||||
*/
|
*/
|
||||||
private extractValuesBlock(reply: string): Record<string, string> | null {
|
private extractValuesBlock(reply: string): Record<string, string> | null {
|
||||||
const match = reply.match(/<values>\s*([\s\S]*?)\s*<\/values>/i);
|
// Pas de \s* autour de la capture (backtracking quadratique) :
|
||||||
|
// JSON.parse tolère nativement les blancs en tête/queue.
|
||||||
|
const match = reply.match(/<values>([\s\S]*?)<\/values>/i);
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(match[1]) as Record<string, unknown>;
|
const parsed = JSON.parse(match[1]) as Record<string, unknown>;
|
||||||
@@ -342,11 +344,4 @@ Les clés du JSON doivent correspondre EXACTEMENT aux noms de champs indiqués.
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
// Volontairement vide : la sidebar reste prise en charge par le composant
|
|
||||||
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
|
||||||
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
|
||||||
// disparition de la sidebar lors des navigations internes a la section.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,6 +195,6 @@
|
|||||||
[isOpen]="chatOpen"
|
[isOpen]="chatOpen"
|
||||||
[quickSuggestions]="chatQuickSuggestions"
|
[quickSuggestions]="chatQuickSuggestions"
|
||||||
[primaryAction]="chatPrimaryAction"
|
[primaryAction]="chatPrimaryAction"
|
||||||
(close)="chatOpen = false"
|
(closed)="chatOpen = false"
|
||||||
(primaryActionClick)="onChatFillRequested()">
|
(primaryActionClick)="onChatFillRequested()">
|
||||||
</app-ai-chat-drawer>
|
</app-ai-chat-drawer>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
|
import { Component, OnInit, DestroyRef } from '@angular/core';
|
||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
|
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
@@ -44,7 +44,7 @@ import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog
|
|||||||
templateUrl: './page-edit.component.html',
|
templateUrl: './page-edit.component.html',
|
||||||
styleUrls: ['./page-edit.component.scss']
|
styleUrls: ['./page-edit.component.scss']
|
||||||
})
|
})
|
||||||
export class PageEditComponent implements OnInit, OnDestroy {
|
export class PageEditComponent implements OnInit {
|
||||||
readonly Sparkles = Sparkles;
|
readonly Sparkles = Sparkles;
|
||||||
readonly Plus = Plus;
|
readonly Plus = Plus;
|
||||||
readonly Trash2 = Trash2;
|
readonly Trash2 = Trash2;
|
||||||
@@ -84,7 +84,7 @@ export class PageEditComponent implements OnInit, OnDestroy {
|
|||||||
/** Valeurs des champs KEY_VALUE_LIST (liste clé/valeur) : fieldName → (label → valeur). */
|
/** Valeurs des champs KEY_VALUE_LIST (liste clé/valeur) : fieldName → (label → valeur). */
|
||||||
keyValueValues: Record<string, Record<string, string>> = {};
|
keyValueValues: Record<string, Record<string, string>> = {};
|
||||||
/** Valeurs des champs TABLE : fieldName → lignes (colonne → cellule). */
|
/** Valeurs des champs TABLE : fieldName → lignes (colonne → cellule). */
|
||||||
tableValues: Record<string, Array<Record<string, string>>> = {};
|
tableValues: Record<string, Record<string, string>[]> = {};
|
||||||
/** Étiquettes libres (Phase 5B). */
|
/** Étiquettes libres (Phase 5B). */
|
||||||
tags: string[] = [];
|
tags: string[] = [];
|
||||||
/** IDs des pages liées (Phase 5B). */
|
/** IDs des pages liées (Phase 5B). */
|
||||||
@@ -200,7 +200,7 @@ export class PageEditComponent implements OnInit, OnDestroy {
|
|||||||
const imageBase: Record<string, string[]> = {};
|
const imageBase: Record<string, string[]> = {};
|
||||||
const framingBase: Record<string, Record<string, ImageFraming>> = {};
|
const framingBase: Record<string, Record<string, ImageFraming>> = {};
|
||||||
const kvBase: Record<string, Record<string, string>> = {};
|
const kvBase: Record<string, Record<string, string>> = {};
|
||||||
const tableBase: Record<string, Array<Record<string, string>>> = {};
|
const tableBase: Record<string, Record<string, string>[]> = {};
|
||||||
// Les valeurs sont rangées par clé STABLE (id) ; on relit d'abord par clé,
|
// Les valeurs sont rangées par clé STABLE (id) ; on relit d'abord par clé,
|
||||||
// puis par nom (pages dont les valeurs étaient encore rangées par nom — elles
|
// puis par nom (pages dont les valeurs étaient encore rangées par nom — elles
|
||||||
// sont ainsi migrées vers la clé id à la prochaine sauvegarde).
|
// sont ainsi migrées vers la clé id à la prochaine sauvegarde).
|
||||||
@@ -259,7 +259,9 @@ export class PageEditComponent implements OnInit, OnDestroy {
|
|||||||
addTableRow(fieldName: string, columns: string[] | null | undefined): void {
|
addTableRow(fieldName: string, columns: string[] | null | undefined): void {
|
||||||
const row: Record<string, string> = {};
|
const row: Record<string, string> = {};
|
||||||
for (const col of columns ?? []) row[col] = '';
|
for (const col of columns ?? []) row[col] = '';
|
||||||
(this.tableValues[fieldName] ??= []).push(row);
|
const rows = this.tableValues[fieldName] ?? [];
|
||||||
|
rows.push(row);
|
||||||
|
this.tableValues[fieldName] = rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
removeTableRow(fieldName: string, rowIndex: number): void {
|
removeTableRow(fieldName: string, rowIndex: number): void {
|
||||||
@@ -267,7 +269,7 @@ export class PageEditComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Lignes du tableau d'un champ — toujours un tableau (jamais undefined) pour le `@for`. */
|
/** Lignes du tableau d'un champ — toujours un tableau (jamais undefined) pour le `@for`. */
|
||||||
tableRows(fieldName: string): Array<Record<string, string>> {
|
tableRows(fieldName: string): Record<string, string>[] {
|
||||||
return this.tableValues[fieldName] ?? [];
|
return this.tableValues[fieldName] ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,11 +345,4 @@ export class PageEditComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
// Volontairement vide : la sidebar reste prise en charge par le composant
|
|
||||||
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
|
||||||
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
|
||||||
// disparition de la sidebar lors des navigations internes a la section.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
|
import { Component, OnInit, DestroyRef } from '@angular/core';
|
||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
|
|
||||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||||
@@ -36,7 +36,7 @@ import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog
|
|||||||
templateUrl: './page-view.component.html',
|
templateUrl: './page-view.component.html',
|
||||||
styleUrls: ['./page-view.component.scss']
|
styleUrls: ['./page-view.component.scss']
|
||||||
})
|
})
|
||||||
export class PageViewComponent implements OnInit, OnDestroy {
|
export class PageViewComponent implements OnInit {
|
||||||
readonly Pencil = Pencil;
|
readonly Pencil = Pencil;
|
||||||
readonly Trash2 = Trash2;
|
readonly Trash2 = Trash2;
|
||||||
|
|
||||||
@@ -155,7 +155,7 @@ export class PageViewComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Lignes d'un champ TABLE (liste vide si jamais rempli). */
|
/** Lignes d'un champ TABLE (liste vide si jamais rempli). */
|
||||||
tableRowsOf(field: TemplateField): Array<Record<string, string>> {
|
tableRowsOf(field: TemplateField): Record<string, string>[] {
|
||||||
const v = this.page?.tableValues;
|
const v = this.page?.tableValues;
|
||||||
return v?.[blockKey(field)] ?? v?.[field.name] ?? [];
|
return v?.[blockKey(field)] ?? v?.[field.name] ?? [];
|
||||||
}
|
}
|
||||||
@@ -196,11 +196,4 @@ export class PageViewComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
// Volontairement vide : la sidebar reste prise en charge par le composant
|
|
||||||
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
|
||||||
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
|
||||||
// disparition de la sidebar lors des navigations internes a la section.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
|
||||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||||
@@ -24,7 +24,7 @@ import { popReturnTo } from '../return-stack.helper';
|
|||||||
templateUrl: './template-create.component.html',
|
templateUrl: './template-create.component.html',
|
||||||
styleUrls: ['./template-create.component.scss']
|
styleUrls: ['./template-create.component.scss']
|
||||||
})
|
})
|
||||||
export class TemplateCreateComponent implements OnInit, OnDestroy {
|
export class TemplateCreateComponent implements OnInit {
|
||||||
form: FormGroup;
|
form: FormGroup;
|
||||||
loreId = '';
|
loreId = '';
|
||||||
nodes: LoreNode[] = [];
|
nodes: LoreNode[] = [];
|
||||||
@@ -85,7 +85,7 @@ export class TemplateCreateComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private restoreDraft(): void {
|
private restoreDraft(): void {
|
||||||
let raw: string | null = null;
|
let raw: string | null;
|
||||||
try { raw = sessionStorage.getItem(this.draftKey); } catch { return; }
|
try { raw = sessionStorage.getItem(this.draftKey); } catch { return; }
|
||||||
if (!raw) return;
|
if (!raw) return;
|
||||||
sessionStorage.removeItem(this.draftKey);
|
sessionStorage.removeItem(this.draftKey);
|
||||||
@@ -146,11 +146,4 @@ export class TemplateCreateComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
this.router.navigate(['/lore', this.loreId]);
|
this.router.navigate(['/lore', this.loreId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
// Volontairement vide : la sidebar reste prise en charge par le composant
|
|
||||||
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
|
||||||
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
|
||||||
// disparition de la sidebar lors des navigations internes a la section.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { Observable, catchError, of } from 'rxjs';
|
import { Observable, catchError, map, of } from 'rxjs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reflet de LicenseStatus (enum cote backend).
|
* Reflet de LicenseStatus (enum cote backend).
|
||||||
@@ -44,13 +44,13 @@ export interface BetaStatusDTO {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
updateAvailable: boolean;
|
updateAvailable: boolean;
|
||||||
anyUnknown: boolean;
|
anyUnknown: boolean;
|
||||||
images: Array<{
|
images: {
|
||||||
image: string;
|
image: string;
|
||||||
localVersion: string | null;
|
localVersion: string | null;
|
||||||
remoteVersion: string | null;
|
remoteVersion: string | null;
|
||||||
status: 'UP_TO_DATE' | 'UPDATE_AVAILABLE' | 'UNKNOWN';
|
status: 'UP_TO_DATE' | 'UPDATE_AVAILABLE' | 'UNKNOWN';
|
||||||
updateAvailable: boolean;
|
updateAvailable: boolean;
|
||||||
}>;
|
}[];
|
||||||
checkedAt: string;
|
checkedAt: string;
|
||||||
disabledReason: string | null;
|
disabledReason: string | null;
|
||||||
}
|
}
|
||||||
@@ -86,10 +86,13 @@ export class LicenseService {
|
|||||||
|
|
||||||
disconnect(): Observable<boolean> {
|
disconnect(): Observable<boolean> {
|
||||||
return this.http.delete<void>(this.apiUrl, this.authOptions).pipe(
|
return this.http.delete<void>(this.apiUrl, this.authOptions).pipe(
|
||||||
// Convertit en boolean : true = succes, false = erreur
|
// Convertit en boolean : true = succes, false = erreur.
|
||||||
// (catchError plus bas masque les detail HTTP)
|
// (Avant : le cast `as unknown as` masquait l'absence de map() — le
|
||||||
catchError(() => of(false as any))
|
// succes emettait `undefined`, donc falsy. Consommateur actuel ignore
|
||||||
) as unknown as Observable<boolean>;
|
// la valeur, mais le type est desormais honnete.)
|
||||||
|
map(() => true),
|
||||||
|
catchError(() => of(false))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
refresh(): Observable<LicenseStatusDTO | null> {
|
refresh(): Observable<LicenseStatusDTO | null> {
|
||||||
|
|||||||
@@ -48,7 +48,9 @@ export interface NotebookAction {
|
|||||||
resolution?: string;
|
resolution?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTION_RE = /```loremind-action\s*([\s\S]*?)```/g;
|
// Pas de \s* avant la capture (backtracking quadratique) : la capture est
|
||||||
|
// trim()ée avant JSON.parse de toute façon.
|
||||||
|
const ACTION_RE = /```loremind-action([\s\S]*?)```/g;
|
||||||
const VALID_TYPES = new Set(['npc', 'scene', 'chapter', 'arc', 'table']);
|
const VALID_TYPES = new Set(['npc', 'scene', 'chapter', 'arc', 'table']);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ import { Notebook, NotebookArchive, NotebookDetail, NotebookSource, NotebookChat
|
|||||||
import { LanguageService } from './language.service';
|
import { LanguageService } from './language.service';
|
||||||
import { parseSseStream } from '../shared/sse.util';
|
import { parseSseStream } from '../shared/sse.util';
|
||||||
|
|
||||||
|
/** Normalise les sources renvoyées par le Brain (événement SSE `sources`). */
|
||||||
|
function mapSseSources(sources: { source_id?: string; page?: number | null; score?: number }[] | undefined) {
|
||||||
|
return (sources ?? []).map(s => ({
|
||||||
|
sourceId: s.source_id ?? '', page: s.page ?? null, score: s.score ?? 0
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service des notebooks (atelier RAG) : CRUD, upload/indexation de sources,
|
* Service des notebooks (atelier RAG) : CRUD, upload/indexation de sources,
|
||||||
* et chat ancré STREAMÉ (SSE via fetch, comme ai-chat.service).
|
* et chat ancré STREAMÉ (SSE via fetch, comme ai-chat.service).
|
||||||
@@ -96,12 +103,7 @@ export class NotebookService {
|
|||||||
} else if (event === 'sources') {
|
} else if (event === 'sources') {
|
||||||
try {
|
try {
|
||||||
const o = JSON.parse(data) as { sources?: { source_id?: string; page?: number | null; score?: number }[] };
|
const o = JSON.parse(data) as { sources?: { source_id?: string; page?: number | null; score?: number }[] };
|
||||||
emit({
|
emit({ type: 'sources', sources: mapSseSources(o.sources) });
|
||||||
type: 'sources',
|
|
||||||
sources: (o.sources ?? []).map(s => ({
|
|
||||||
sourceId: s.source_id ?? '', page: s.page ?? null, score: s.score ?? 0
|
|
||||||
}))
|
|
||||||
});
|
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
} else if (event === 'progress') {
|
} else if (event === 'progress') {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export interface Page {
|
|||||||
* Pour chaque champ TABLE (colonnes figees au template, lignes libres) :
|
* Pour chaque champ TABLE (colonnes figees au template, lignes libres) :
|
||||||
* fieldName → lignes ordonnees, chaque ligne = colonne → cellule.
|
* fieldName → lignes ordonnees, chaque ligne = colonne → cellule.
|
||||||
*/
|
*/
|
||||||
tableValues?: Record<string, Array<Record<string, string>>>;
|
tableValues?: Record<string, Record<string, string>[]>;
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
relatedPageIds?: string[];
|
relatedPageIds?: string[];
|
||||||
|
|||||||
@@ -46,9 +46,10 @@ export class VersionCheckerService {
|
|||||||
*/
|
*/
|
||||||
start(): void {
|
start(): void {
|
||||||
if (this.timer !== null) return;
|
if (this.timer !== null) return;
|
||||||
void this.fetchInitial();
|
// Fire-and-forget : fetchInitial/poll gèrent leurs erreurs en interne.
|
||||||
|
this.fetchInitial();
|
||||||
this.timer = setInterval(
|
this.timer = setInterval(
|
||||||
() => void this.poll(),
|
() => { this.poll(); },
|
||||||
VersionCheckerService.POLL_INTERVAL_MS,
|
VersionCheckerService.POLL_INTERVAL_MS,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
name="editName"
|
name="editName"
|
||||||
(keydown.enter)="saveRename()"
|
(keydown.enter)="saveRename()"
|
||||||
(keydown.escape)="cancelRename()"
|
(keydown.escape)="cancelRename()"
|
||||||
autofocus />
|
/>
|
||||||
<button type="button" class="btn-icon" (click)="saveRename()" [disabled]="!editName.trim()" [title]="'common.validate' | translate">
|
<button type="button" class="btn-icon" (click)="saveRename()" [disabled]="!editName.trim()" [title]="'common.validate' | translate">
|
||||||
<lucide-icon [img]="Check" [size]="14"></lucide-icon>
|
<lucide-icon [img]="Check" [size]="14"></lucide-icon>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -281,11 +281,14 @@ export class SessionDetailComponent implements OnInit, OnDestroy {
|
|||||||
if (!this.session) return;
|
if (!this.session) return;
|
||||||
const session = this.session;
|
const session = this.session;
|
||||||
const entryCount = this.entries.length;
|
const entryCount = this.entries.length;
|
||||||
const entriesDetail = entryCount === 0
|
let entriesDetail: string;
|
||||||
? this.translate.instant('sessionDetail.deleteConfirm.noEntries')
|
if (entryCount === 0) {
|
||||||
: entryCount === 1
|
entriesDetail = this.translate.instant('sessionDetail.deleteConfirm.noEntries');
|
||||||
? this.translate.instant('sessionDetail.deleteConfirm.entriesOne')
|
} else if (entryCount === 1) {
|
||||||
: this.translate.instant('sessionDetail.deleteConfirm.entriesMany', { n: entryCount });
|
entriesDetail = this.translate.instant('sessionDetail.deleteConfirm.entriesOne');
|
||||||
|
} else {
|
||||||
|
entriesDetail = this.translate.instant('sessionDetail.deleteConfirm.entriesMany', { n: entryCount });
|
||||||
|
}
|
||||||
const details = [
|
const details = [
|
||||||
entriesDetail,
|
entriesDetail,
|
||||||
this.translate.instant('sessionDetail.deleteConfirm.irreversible')
|
this.translate.instant('sessionDetail.deleteConfirm.irreversible')
|
||||||
|
|||||||
@@ -58,7 +58,9 @@ export class SessionDicePanelComponent {
|
|||||||
}
|
}
|
||||||
const sumRolls = rolls.reduce((s, n) => s + n, 0);
|
const sumRolls = rolls.reduce((s, n) => s + n, 0);
|
||||||
const total = sumRolls + this.modifier;
|
const total = sumRolls + this.modifier;
|
||||||
const modPart = this.modifier === 0 ? '' : (this.modifier > 0 ? `+${this.modifier}` : `${this.modifier}`);
|
let modPart = '';
|
||||||
|
if (this.modifier > 0) modPart = `+${this.modifier}`;
|
||||||
|
else if (this.modifier < 0) modPart = `${this.modifier}`;
|
||||||
const notation = `${safeCount}d${this.selectedFace}${modPart}`;
|
const notation = `${safeCount}d${this.selectedFace}${modPart}`;
|
||||||
const detailsPart = rolls.length > 1 ? ` [${rolls.join(', ')}]` : '';
|
const detailsPart = rolls.length > 1 ? ` [${rolls.join(', ')}]` : '';
|
||||||
const summary = `🎲 ${notation}${detailsPart} = ${total}`;
|
const summary = `🎲 ${notation}${detailsPart} = ${total}`;
|
||||||
|
|||||||
@@ -52,7 +52,11 @@ export class SessionItemCatalogsPanelComponent implements OnInit {
|
|||||||
map.get(cat)!.push(it);
|
map.get(cat)!.push(it);
|
||||||
}
|
}
|
||||||
return [...map.entries()]
|
return [...map.entries()]
|
||||||
.sort(([a], [b]) => (a === '—' ? 1 : b === '—' ? -1 : a.localeCompare(b, 'fr')))
|
.sort(([a], [b]) => {
|
||||||
|
if (a === '—') return 1; // catégorie "sans catégorie" toujours en dernier
|
||||||
|
if (b === '—') return -1;
|
||||||
|
return a.localeCompare(b, 'fr');
|
||||||
|
})
|
||||||
.map(([category, items]) => ({ category, items }));
|
.map(([category, items]) => ({ category, items }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -209,9 +209,10 @@ export class OllamaModelManagerComponent implements OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractError(err: any, fallback: string): string {
|
private extractError(err: unknown, fallback: string): string {
|
||||||
if (err?.error?.detail) return String(err.error.detail);
|
const e = err as { error?: { detail?: unknown }; message?: string } | null;
|
||||||
if (err?.message) return err.message;
|
if (e?.error?.detail) return String(e.error.detail);
|
||||||
|
if (e?.message) return e.message;
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export class SettingsComponent implements OnInit {
|
|||||||
/** Catalogue Gemini (dynamique si cle configuree, repli statique sinon). */
|
/** Catalogue Gemini (dynamique si cle configuree, repli statique sinon). */
|
||||||
geminiModels: GeminiModel[] = [];
|
geminiModels: GeminiModel[] = [];
|
||||||
/** Fournisseur 1min.ai actuellement selectionne (filtre la liste des modeles). */
|
/** Fournisseur 1min.ai actuellement selectionne (filtre la liste des modeles). */
|
||||||
oneminProvider: string = '';
|
oneminProvider = '';
|
||||||
|
|
||||||
loadingModels = false;
|
loadingModels = false;
|
||||||
saving = false;
|
saving = false;
|
||||||
@@ -419,9 +419,10 @@ export class SettingsComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractError(err: any, fallback: string): string {
|
private extractError(err: unknown, fallback: string): string {
|
||||||
if (err?.error?.detail) return String(err.error.detail);
|
const e = err as { error?: { detail?: unknown }; message?: string } | null;
|
||||||
if (err?.message) return err.message;
|
if (e?.error?.detail) return String(e.error.detail);
|
||||||
|
if (e?.message) return e.message;
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,11 +130,12 @@ export class UpdatesSectionComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
this.licenseError = '';
|
this.licenseError = '';
|
||||||
this.licenseService.install(jwt).subscribe((res) => {
|
this.licenseService.install(jwt).subscribe((res) => {
|
||||||
if ((res as any)?.error) {
|
// install() renvoie une union typée : le garde `in` suffit à discriminer.
|
||||||
this.licenseError = (res as any).error;
|
if ('error' in res) {
|
||||||
|
this.licenseError = res.error;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.licenseStatus = res as LicenseStatusDTO;
|
this.licenseStatus = res;
|
||||||
this.licenseJwtInput = '';
|
this.licenseJwtInput = '';
|
||||||
this.licenseSuccess = this.translate.instant('updatesSection.patreonConnectedSuccess');
|
this.licenseSuccess = this.translate.instant('updatesSection.patreonConnectedSuccess');
|
||||||
if (this.licenseStatus.betaChannelEnabled) {
|
if (this.licenseStatus.betaChannelEnabled) {
|
||||||
|
|||||||
@@ -80,7 +80,7 @@
|
|||||||
(keyup.enter)="submitRenameTitle()"
|
(keyup.enter)="submitRenameTitle()"
|
||||||
(keyup.escape)="cancelRenameTitle()"
|
(keyup.escape)="cancelRenameTitle()"
|
||||||
(blur)="submitRenameTitle()"
|
(blur)="submitRenameTitle()"
|
||||||
autofocus />
|
/>
|
||||||
}
|
}
|
||||||
} @else {
|
} @else {
|
||||||
<h2 class="header-title">{{ 'aiChatDrawer.title' | translate }}</h2>
|
<h2 class="header-title">{{ 'aiChatDrawer.title' | translate }}</h2>
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
/** Persistance activee ? false = mode wizard ephemere. */
|
/** Persistance activee ? false = mode wizard ephemere. */
|
||||||
@Input() persistent = true;
|
@Input() persistent = true;
|
||||||
|
|
||||||
@Output() close = new EventEmitter<void>();
|
@Output() closed = new EventEmitter<void>();
|
||||||
@Output() primaryActionClick = new EventEmitter<void>();
|
@Output() primaryActionClick = new EventEmitter<void>();
|
||||||
@Output() assistantReply = new EventEmitter<string>();
|
@Output() assistantReply = new EventEmitter<string>();
|
||||||
|
|
||||||
@@ -180,7 +180,7 @@ export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
this.isWide = !this.isWide;
|
this.isWide = !this.isWide;
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(this.LS_WIDE, this.isWide ? '1' : '0');
|
localStorage.setItem(this.LS_WIDE, this.isWide ? '1' : '0');
|
||||||
} catch {}
|
} catch { /* localStorage indisponible : ignoré */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Debut du drag : enregistre la position de depart + abonne listeners globaux. */
|
/** Debut du drag : enregistre la position de depart + abonne listeners globaux. */
|
||||||
@@ -216,7 +216,7 @@ export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
if (this.customWidth !== null) {
|
if (this.customWidth !== null) {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(this.LS_WIDTH, String(this.customWidth));
|
localStorage.setItem(this.LS_WIDTH, String(this.customWidth));
|
||||||
} catch {}
|
} catch { /* localStorage indisponible : ignoré */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,7 +231,7 @@ export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.isWide = localStorage.getItem(this.LS_WIDE) === '1';
|
this.isWide = localStorage.getItem(this.LS_WIDE) === '1';
|
||||||
} catch {}
|
} catch { /* localStorage indisponible : ignoré */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnChanges(changes: SimpleChanges): void {
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
@@ -374,7 +374,7 @@ export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
|
|
||||||
onClose(): void {
|
onClose(): void {
|
||||||
this.abortStream();
|
this.abortStream();
|
||||||
this.close.emit();
|
this.closed.emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
send(): void {
|
send(): void {
|
||||||
@@ -450,7 +450,7 @@ export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
this.scrollToBottom();
|
this.scrollToBottom();
|
||||||
|
|
||||||
if (convId) {
|
if (convId) {
|
||||||
this.conversationService.appendMessage(convId, 'user', text).subscribe({ error: () => {} });
|
this.conversationService.appendMessage(convId, 'user', text).subscribe({ error: () => { /* best-effort : erreur ignorée volontairement */ } });
|
||||||
}
|
}
|
||||||
|
|
||||||
this.streamSub = this.buildStream().subscribe({
|
this.streamSub = this.buildStream().subscribe({
|
||||||
@@ -477,7 +477,7 @@ export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
next: () => {
|
next: () => {
|
||||||
if (wasEmpty) this.triggerAutoTitle(convId);
|
if (wasEmpty) this.triggerAutoTitle(convId);
|
||||||
},
|
},
|
||||||
error: () => {},
|
error: () => { /* best-effort : erreur ignorée volontairement */ },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -505,7 +505,7 @@ export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
c.id === convId ? { ...c, title } : c,
|
c.id === convId ? { ...c, title } : c,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
error: () => {},
|
error: () => { /* best-effort : erreur ignorée volontairement */ },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { TranslatePipe } from '@ngx-translate/core';
|
|||||||
*/
|
*/
|
||||||
export interface BreadcrumbItem {
|
export interface BreadcrumbItem {
|
||||||
label: string;
|
label: string;
|
||||||
route?: string | any[];
|
route?: string | unknown[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ export class DiceUtils {
|
|||||||
|
|
||||||
/** Parse une formule simple `[N]dM`. Renvoie null si invalide. */
|
/** Parse une formule simple `[N]dM`. Renvoie null si invalide. */
|
||||||
static parse(formula: string | null | undefined): ParsedDice | null {
|
static parse(formula: string | null | undefined): ParsedDice | null {
|
||||||
const m = /^\s*(\d*)\s*[dD]\s*(\d+)\s*$/.exec(formula ?? '');
|
// trim() préalable au lieu de \s* aux extrémités : `\s*(\d*)\s*` était
|
||||||
|
// ambigu (quadratique) quand \d* est vide. Même langage accepté.
|
||||||
|
const m = /^(\d*)\s*[dD]\s*(\d+)$/.exec((formula ?? '').trim());
|
||||||
if (!m) return null;
|
if (!m) return null;
|
||||||
const count = m[1] ? parseInt(m[1], 10) : 1;
|
const count = m[1] ? parseInt(m[1], 10) : 1;
|
||||||
const faces = parseInt(m[2], 10);
|
const faces = parseInt(m[2], 10);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, Input } from '@angular/core';
|
import { Component, Input, OnInit } from '@angular/core';
|
||||||
|
|
||||||
import { LucideAngularModule, ChevronDown, ChevronUp } from 'lucide-angular';
|
import { LucideAngularModule, ChevronDown, ChevronUp } from 'lucide-angular';
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ import { LucideAngularModule, ChevronDown, ChevronUp } from 'lucide-angular';
|
|||||||
templateUrl: './expandable-section.component.html',
|
templateUrl: './expandable-section.component.html',
|
||||||
styleUrls: ['./expandable-section.component.scss']
|
styleUrls: ['./expandable-section.component.scss']
|
||||||
})
|
})
|
||||||
export class ExpandableSectionComponent {
|
export class ExpandableSectionComponent implements OnInit {
|
||||||
readonly ChevronDown = ChevronDown;
|
readonly ChevronDown = ChevronDown;
|
||||||
readonly ChevronUp = ChevronUp;
|
readonly ChevronUp = ChevronUp;
|
||||||
|
|
||||||
|
|||||||
@@ -12,24 +12,34 @@ import { PageService } from '../../services/page.service';
|
|||||||
import { TemplateService } from '../../services/template.service';
|
import { TemplateService } from '../../services/template.service';
|
||||||
import { CampaignService } from '../../services/campaign.service';
|
import { CampaignService } from '../../services/campaign.service';
|
||||||
import { NpcService } from '../../services/npc.service';
|
import { NpcService } from '../../services/npc.service';
|
||||||
import { CharacterService } from '../../services/character.service';
|
import { CharacterService, CharacterSearchResult } from '../../services/character.service';
|
||||||
import { RandomTableService } from '../../services/random-table.service';
|
import { RandomTableService } from '../../services/random-table.service';
|
||||||
import { ItemCatalogService } from '../../services/item-catalog.service';
|
import { ItemCatalogService } from '../../services/item-catalog.service';
|
||||||
import { EnemyService } from '../../services/enemy.service';
|
import { EnemyService } from '../../services/enemy.service';
|
||||||
|
import { Lore, LoreNode } from '../../services/lore.model';
|
||||||
|
import { Template } from '../../services/template.model';
|
||||||
|
import { Page } from '../../services/page.model';
|
||||||
|
import { Campaign } from '../../services/campaign.model';
|
||||||
|
import { Npc } from '../../services/npc.model';
|
||||||
|
import { RandomTable } from '../../services/random-table.model';
|
||||||
|
import { ItemCatalog } from '../../services/item-catalog.model';
|
||||||
|
import { Enemy } from '../../services/enemy.model';
|
||||||
|
|
||||||
type ResultKind =
|
type ResultKind =
|
||||||
| 'lore' | 'node' | 'template' | 'page' | 'campaign'
|
| 'lore' | 'node' | 'template' | 'page' | 'campaign'
|
||||||
| 'npc' | 'character' | 'random-table' | 'item-catalog' | 'enemy';
|
| 'npc' | 'character' | 'random-table' | 'item-catalog' | 'enemy';
|
||||||
|
|
||||||
interface SearchResult {
|
interface SearchResult {
|
||||||
id: string;
|
/** Optionnel dans les DTOs (objets pas encore persistés) mais toujours
|
||||||
|
* présent sur des résultats de recherche serveur. */
|
||||||
|
id: string | undefined;
|
||||||
kind: ResultKind;
|
kind: ResultKind;
|
||||||
title: string;
|
title: string;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
/** Tag affiché sous le titre (ex: "Lore", "Dossier", "Template", "Page"). */
|
/** Tag affiché sous le titre (ex: "Lore", "Dossier", "Template", "Page"). */
|
||||||
tag: string;
|
tag: string;
|
||||||
/** Route Angular (array pour router.navigate). */
|
/** Route Angular (array pour router.navigate). */
|
||||||
route: any[];
|
route: (string | undefined)[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -140,8 +150,8 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
|||||||
* noeuds/templates, et enfin les racines (campagnes, lores).
|
* noeuds/templates, et enfin les racines (campagnes, lores).
|
||||||
*/
|
*/
|
||||||
private buildResults(r: {
|
private buildResults(r: {
|
||||||
lores: any[]; nodes: any[]; templates: any[]; pages: any[]; campaigns: any[];
|
lores: Lore[]; nodes: LoreNode[]; templates: Template[]; pages: Page[]; campaigns: Campaign[];
|
||||||
npcs: any[]; characters: any[]; tables: any[]; catalogs: any[]; enemies: any[];
|
npcs: Npc[]; characters: CharacterSearchResult[]; tables: RandomTable[]; catalogs: ItemCatalog[]; enemies: Enemy[];
|
||||||
}): SearchResult[] {
|
}): SearchResult[] {
|
||||||
const { lores, nodes, templates, pages, campaigns, npcs, characters, tables, catalogs, enemies } = r;
|
const { lores, nodes, templates, pages, campaigns, npcs, characters, tables, catalogs, enemies } = r;
|
||||||
const pageResults: SearchResult[] = pages.map(p => ({
|
const pageResults: SearchResult[] = pages.map(p => ({
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ export class ImageBlockComponent implements OnDestroy {
|
|||||||
const id = this.currentId;
|
const id = this.currentId;
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
// Best-effort côté serveur (pas d'orpheline) ; on n'attend pas la réponse.
|
// Best-effort côté serveur (pas d'orpheline) ; on n'attend pas la réponse.
|
||||||
this.imageService.delete(id).subscribe({ error: () => {} });
|
this.imageService.delete(id).subscribe({ error: () => { /* best-effort : erreur ignorée volontairement */ } });
|
||||||
const ids = this.imageIds.filter(i => i !== id);
|
const ids = this.imageIds.filter(i => i !== id);
|
||||||
if (this.framing?.[id]) {
|
if (this.framing?.[id]) {
|
||||||
const next = { ...this.framing };
|
const next = { ...this.framing };
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export class ImageGalleryComponent {
|
|||||||
event.stopPropagation(); // Evite d'ouvrir le lightbox en cliquant sur X.
|
event.stopPropagation(); // Evite d'ouvrir le lightbox en cliquant sur X.
|
||||||
// On supprime aussi cote serveur pour ne pas laisser d'image orpheline.
|
// On supprime aussi cote serveur pour ne pas laisser d'image orpheline.
|
||||||
// Best-effort : on n'attend pas le retour pour emettre la nouvelle liste.
|
// Best-effort : on n'attend pas le retour pour emettre la nouvelle liste.
|
||||||
this.imageService.delete(id).subscribe({ error: () => {} });
|
this.imageService.delete(id).subscribe({ error: () => { /* best-effort : erreur ignorée volontairement */ } });
|
||||||
this.imageIdsChange.emit(this.imageIds.filter(i => i !== id));
|
this.imageIdsChange.emit(this.imageIds.filter(i => i !== id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ export class MarkdownPipe implements PipeTransform {
|
|||||||
if (!value) return '';
|
if (!value) return '';
|
||||||
const html = marked.parse(value, { async: false, gfm: true, breaks: true }) as string;
|
const html = marked.parse(value, { async: false, gfm: true, breaks: true }) as string;
|
||||||
const clean = DOMPurify.sanitize(html);
|
const clean = DOMPurify.sanitize(html);
|
||||||
|
// Revue sécurité : le bypass est sûr ICI car le HTML vient d'être passé
|
||||||
|
// par DOMPurify juste au-dessus (le sanitizer Angular, moins permissif,
|
||||||
|
// casserait le rendu markdown). Ne jamais bypasser sans DOMPurify amont.
|
||||||
|
// eslint-disable-next-line sonarjs/no-angular-bypass-sanitization
|
||||||
return this.sanitizer.bypassSecurityTrustHtml(clean);
|
return this.sanitizer.bypassSecurityTrustHtml(clean);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,8 +36,9 @@
|
|||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Mode normal : spacer + outils -->
|
<!-- Mode normal : spacer + outils. `=== null` (et pas `!`) : l'async pipe
|
||||||
@if (!(layoutConfig$ | async)) {
|
émet null tant que rien n'est reçu, la négation brute serait ambiguë. -->
|
||||||
|
@if ((layoutConfig$ | async) === null) {
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
"baseUrl": "./",
|
"baseUrl": "./",
|
||||||
"outDir": "./dist/out-tsc",
|
"outDir": "./dist/out-tsc",
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": [],
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noImplicitOverride": true,
|
"noImplicitOverride": true,
|
||||||
"noPropertyAccessFromIndexSignature": true,
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
@@ -29,6 +31,7 @@
|
|||||||
"strictTemplates": true
|
"strictTemplates": true
|
||||||
},
|
},
|
||||||
"exclude": [
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
"e2e/**/*",
|
"e2e/**/*",
|
||||||
"playwright.config.ts",
|
"playwright.config.ts",
|
||||||
"vitest.config.ts",
|
"vitest.config.ts",
|
||||||
|
|||||||
Reference in New Issue
Block a user