Compare commits
2 Commits
7c74c12f3e
...
v0.8.4
| Author | SHA1 | Date | |
|---|---|---|---|
| 0cd99dfb32 | |||
| f24ef0891e |
@@ -42,19 +42,24 @@ jobs:
|
||||
username: ${{ env.GHCR_NAMESPACE }}
|
||||
password: ${{ secrets.GHCR_TOKEN }}
|
||||
|
||||
- name: Extract version
|
||||
# Detection du canal :
|
||||
# - tag vX.Y.Z -> stable (push :latest + :version sur les repos publics)
|
||||
# - tag vX.Y.Z-beta* -> beta (push :beta + :version sur les repos GHCR prives
|
||||
# loremind-beta-<component> ; backup Gitea avec :version)
|
||||
- name: Extract version & channel
|
||||
id: meta
|
||||
run: echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||
if [[ "${VERSION}" == *-beta* ]]; then
|
||||
echo "channel=beta" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "channel=stable" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# Push vers les deux registries en un seul build (build-push-action
|
||||
# accepte une liste de tags ; aucun build supplementaire necessaire).
|
||||
# Naming :
|
||||
# - Gitea : conserve l'ancien pattern ietm64/<component> pour ne pas
|
||||
# casser les installs existantes qui ont REGISTRY=git.igmlcreation.fr
|
||||
# dans leur .env.
|
||||
# - GHCR : nouveau pattern igmlcreation/loremind-<component> qui evite
|
||||
# la collision avec d'autres projets de l'org.
|
||||
- name: Build & push ${{ matrix.component }}
|
||||
# Build & push canal STABLE
|
||||
- name: Build & push ${{ matrix.component }} (stable)
|
||||
if: steps.meta.outputs.channel == 'stable'
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./${{ matrix.component }}
|
||||
@@ -64,3 +69,19 @@ jobs:
|
||||
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/${{ matrix.component }}:${{ steps.meta.outputs.version }}
|
||||
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-${{ matrix.component }}:latest
|
||||
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-${{ matrix.component }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
# Build & push canal BETA
|
||||
# GHCR : repos prives loremind-beta-<component> (gated par PAT distribue
|
||||
# via le relais Patreon aux tiers Compagnon).
|
||||
# Gitea : backup prive avec :version uniquement (pas de :latest pour ne
|
||||
# pas faire upgrader les installs branchees sur Gitea).
|
||||
- name: Build & push ${{ matrix.component }} (beta)
|
||||
if: steps.meta.outputs.channel == 'beta'
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./${{ matrix.component }}
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/${{ matrix.component }}:${{ steps.meta.outputs.version }}
|
||||
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-beta-${{ matrix.component }}:beta
|
||||
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-beta-${{ matrix.component }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -107,3 +107,4 @@ docker-compose.override.yml
|
||||
# Relais OAuth Patreon (repo Gitea separe, clone localement pour facilite)
|
||||
# ============================================================================
|
||||
relay/
|
||||
scripts/bump-version.mjs
|
||||
|
||||
@@ -41,7 +41,7 @@ from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
|
||||
app = FastAPI(
|
||||
title="LoreMind Brain",
|
||||
description="Backend IA pour la génération de contenu narratif.",
|
||||
version="0.8.3",
|
||||
version="0.8.4-beta",
|
||||
)
|
||||
|
||||
|
||||
|
||||
13
core/pom.xml
13
core/pom.xml
@@ -8,13 +8,13 @@
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.2.0</version>
|
||||
<version>3.2.12</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.loremind</groupId>
|
||||
<artifactId>loremind-core</artifactId>
|
||||
<version>0.8.3</version>
|
||||
<version>0.8.4-beta</version>
|
||||
<name>LoreMind Core</name>
|
||||
<description>Backend Core - Architecture Hexagonale</description>
|
||||
|
||||
@@ -96,6 +96,15 @@
|
||||
<artifactId>bcprov-jdk18on</artifactId>
|
||||
<version>1.78.1</version>
|
||||
</dependency>
|
||||
<!-- Google Tink : runtime requis par com.nimbusds.jose.crypto.Ed25519Verifier
|
||||
(depuis Nimbus 9.x, la verification EdDSA delegue a Tink.subtle.Ed25519Verify).
|
||||
Tink n'est PAS une dependance transitive de nimbus-jose-jwt → il faut
|
||||
l'ajouter explicitement, sinon NoClassDefFoundError au premier verify(). -->
|
||||
<dependency>
|
||||
<groupId>com.google.crypto.tink</groupId>
|
||||
<artifactId>tink</artifactId>
|
||||
<version>1.14.1</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
@@ -355,3 +355,55 @@ export async function getTemplateById(
|
||||
expect(res.ok(), `GET /api/templates/${templateId} -> ${res.status()}`).toBeTruthy();
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ─────────────── GameSystem ───────────────
|
||||
|
||||
export interface SeededGameSystem {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export async function seedGameSystem(
|
||||
request: APIRequestContext,
|
||||
opts: { name?: string; description?: string; author?: string; rulesMarkdown?: string } = {},
|
||||
): Promise<SeededGameSystem> {
|
||||
const name = opts.name ?? `E2E GameSystem ${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
||||
const res = await request.post('/api/game-systems', {
|
||||
data: {
|
||||
name,
|
||||
description: opts.description ?? null,
|
||||
author: opts.author ?? null,
|
||||
rulesMarkdown: opts.rulesMarkdown ?? null,
|
||||
characterTemplate: [],
|
||||
npcTemplate: [],
|
||||
isPublic: false,
|
||||
},
|
||||
});
|
||||
expect(res.ok(), `POST /api/game-systems -> ${res.status()}`).toBeTruthy();
|
||||
const gs = await res.json();
|
||||
return { id: gs.id, name };
|
||||
}
|
||||
|
||||
export async function deleteGameSystem(
|
||||
request: APIRequestContext,
|
||||
id: string,
|
||||
): Promise<void> {
|
||||
// Best-effort : ignore 404 si déjà supprimé par le test (ex: delete spec).
|
||||
await request.delete(`/api/game-systems/${id}`);
|
||||
}
|
||||
|
||||
export async function getGameSystemById(
|
||||
request: APIRequestContext,
|
||||
id: string,
|
||||
): Promise<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
author: string | null;
|
||||
rulesMarkdown: string | null;
|
||||
isPublic: boolean;
|
||||
}> {
|
||||
const res = await request.get(`/api/game-systems/${id}`);
|
||||
expect(res.ok(), `GET /api/game-systems/${id} -> ${res.status()}`).toBeTruthy();
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -24,10 +24,12 @@ test.describe('Arc delete', () => {
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
|
||||
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`);
|
||||
await page.getByRole('button', { name: /^Supprimer$/i }).click();
|
||||
await page.getByRole('button', { name: /^Supprimer$/i }).first().click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('button', { name: /^Supprimer$/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}$`));
|
||||
|
||||
@@ -36,10 +38,12 @@ test.describe('Arc delete', () => {
|
||||
});
|
||||
|
||||
test('keeps the arc when confirm is dismissed', async ({ page, request }) => {
|
||||
page.on('dialog', (dialog) => dialog.dismiss());
|
||||
|
||||
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`);
|
||||
await page.getByRole('button', { name: /^Supprimer$/i }).click();
|
||||
await page.getByRole('button', { name: /^Supprimer$/i }).first().click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('button', { name: /^Annuler$/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}/arcs/${arc.id}/edit$`));
|
||||
|
||||
|
||||
@@ -38,6 +38,10 @@ test.describe('Arc edit', () => {
|
||||
};
|
||||
|
||||
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`);
|
||||
// Attend que le formulaire soit prerempli par le ngOnInit (HTTP async) avant
|
||||
// de fill — sinon le patchValue du load arrive APRES nos fills et ecrase
|
||||
// les valeurs, le test echoue alors a la verif persisted.name.
|
||||
await expect(page.getByLabel(/Titre de l'arc/i)).toHaveValue(arc.name);
|
||||
|
||||
await page.getByLabel(/Titre de l'arc/i).fill(newName);
|
||||
await page.getByLabel(/Synopsis de l'arc/i).fill(values.description);
|
||||
|
||||
@@ -16,10 +16,12 @@ test.describe('Campaign delete', () => {
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
|
||||
await page.goto(`/campaigns/${campaign.id}`);
|
||||
await page.getByRole('button', { name: /^Supprimer$/i }).click();
|
||||
await page.getByRole('button', { name: /^Supprimer$/i }).first().click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('button', { name: /^Supprimer$/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/campaigns$/);
|
||||
|
||||
@@ -28,10 +30,12 @@ test.describe('Campaign delete', () => {
|
||||
});
|
||||
|
||||
test('keeps the campaign when confirm is dismissed', async ({ page, request }) => {
|
||||
page.on('dialog', (dialog) => dialog.dismiss());
|
||||
|
||||
await page.goto(`/campaigns/${campaign.id}`);
|
||||
await page.getByRole('button', { name: /^Supprimer$/i }).click();
|
||||
await page.getByRole('button', { name: /^Supprimer$/i }).first().click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('button', { name: /^Annuler$/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}$`));
|
||||
|
||||
|
||||
@@ -17,20 +17,22 @@ test.describe('NPC creation', () => {
|
||||
if (campaign?.id) await deleteCampaign(request, campaign.id);
|
||||
});
|
||||
|
||||
test('creates an NPC and redirects back to the campaign', async ({ page, request }) => {
|
||||
test('creates an NPC and redirects to the NPC detail page', async ({ page, request }) => {
|
||||
// Note : depuis la refonte 2026-04-30 les fiches PNJ utilisent des champs
|
||||
// templates dynamiques pilotes par le GameSystem (plus de markdownContent
|
||||
// libre). La campagne seedee n'a pas de GameSystem donc on ne fill que le
|
||||
// nom — c'est suffisant pour valider la creation + la redirection.
|
||||
const npcName = `Borin le forgeron ${Date.now()}`;
|
||||
const markdown = '# Borin\n\n**Faction :** Clan Feuillefer\n\nNain barbu au regard perçant.';
|
||||
|
||||
await page.goto(`/campaigns/${campaign.id}/npcs/create`);
|
||||
await expect(page.getByRole('heading', { name: /Nouveau PNJ/i })).toBeVisible();
|
||||
|
||||
await page.getByLabel(/Nom du PNJ/i).fill(npcName);
|
||||
await page.getByLabel(/Fiche \(markdown\)/i).fill(markdown);
|
||||
|
||||
await page.getByRole('button', { name: /^Créer$/i }).click();
|
||||
|
||||
// Retour à la page campagne après création
|
||||
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}$`));
|
||||
// Redirection vers la fiche du PNJ après création
|
||||
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}/npcs/\\d+$`));
|
||||
|
||||
// Persistance vérifiée via API
|
||||
const npcs = await getNpcsByCampaign(request, campaign.id);
|
||||
@@ -58,7 +60,7 @@ test.describe('NPC creation', () => {
|
||||
await page.getByLabel(/Nom du PNJ/i).fill(npcName);
|
||||
await page.getByRole('button', { name: /^Créer$/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}$`));
|
||||
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}/npcs/\\d+$`));
|
||||
|
||||
// Le nœud "PNJ" doit apparaître dans la sidebar avec le nouveau PNJ.
|
||||
// On clique sur le nœud PNJ pour le déplier au cas où il serait fermé,
|
||||
|
||||
@@ -14,19 +14,19 @@ test.describe('NPC edit', () => {
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
campaign = await seedCampaign(request);
|
||||
npc = await seedNpc(request, {
|
||||
campaignId: campaign.id,
|
||||
markdownContent: '# Initial\n\nFiche de départ.',
|
||||
});
|
||||
npc = await seedNpc(request, { campaignId: campaign.id });
|
||||
});
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
if (campaign?.id) await deleteCampaign(request, campaign.id);
|
||||
});
|
||||
|
||||
test('edits name + markdown content and persists via API', async ({ page, request }) => {
|
||||
test('edits name and persists via API', async ({ page, request }) => {
|
||||
// Note : depuis la refonte 2026-04-30 les fiches PNJ utilisent des champs
|
||||
// templates dynamiques pilotes par le GameSystem, plus le markdownContent
|
||||
// libre. La campagne seedee n'a pas de GameSystem donc pas de champs
|
||||
// dynamiques a tester ici — on se contente du nom (champ universel).
|
||||
const newName = `${npc.name} (renommé)`;
|
||||
const newMarkdown = '# Borin réécrit\n\n**Statut :** Disparu\n\nDes traces dans la neige...';
|
||||
|
||||
await page.goto(`/campaigns/${campaign.id}/npcs/${npc.id}/edit`);
|
||||
|
||||
@@ -34,7 +34,6 @@ test.describe('NPC edit', () => {
|
||||
await expect(page.getByLabel(/Nom du PNJ/i)).toHaveValue(npc.name);
|
||||
|
||||
await page.getByLabel(/Nom du PNJ/i).fill(newName);
|
||||
await page.getByLabel(/Fiche \(markdown\)/i).fill(newMarkdown);
|
||||
|
||||
await page.getByRole('button', { name: /^Enregistrer$/i }).click();
|
||||
|
||||
@@ -43,7 +42,6 @@ test.describe('NPC edit', () => {
|
||||
|
||||
const persisted = await getNpcById(request, npc.id);
|
||||
expect(persisted.name).toBe(newName);
|
||||
expect(persisted.markdownContent).toBe(newMarkdown);
|
||||
});
|
||||
|
||||
test('save button is disabled when name is cleared', async ({ page }) => {
|
||||
|
||||
74
web/e2e/tests/game-system/game-system-create.spec.ts
Normal file
74
web/e2e/tests/game-system/game-system-create.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { deleteGameSystem } from '../../fixtures/api';
|
||||
|
||||
test.describe('GameSystem creation', () => {
|
||||
// Les game systems crees par les tests sont nettoyes via cet array — chaque
|
||||
// test pousse les IDs qu'il a crees pour qu'on les supprime en afterEach.
|
||||
const createdIds: string[] = [];
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
while (createdIds.length) {
|
||||
const id = createdIds.pop()!;
|
||||
await deleteGameSystem(request, id);
|
||||
}
|
||||
});
|
||||
|
||||
test('creates a game system and redirects to the list', async ({ page, request }) => {
|
||||
const gsName = `Système E2E ${Date.now()}`;
|
||||
const description = 'Système créé par les tests automatisés.';
|
||||
const author = 'Playwright';
|
||||
|
||||
await page.goto('/game-systems');
|
||||
await expect(page.getByRole('heading', { name: /Systèmes de JDR/i })).toBeVisible();
|
||||
|
||||
// Carte "Nouveau système" → ouvre l'editeur en mode creation.
|
||||
await page.locator('.gs-card.card-new').click();
|
||||
await expect(page).toHaveURL(/\/game-systems\/create$/);
|
||||
await expect(page.getByRole('heading', { name: /Nouveau système de JDR/i })).toBeVisible();
|
||||
|
||||
await page.getByLabel(/^Nom/i).fill(gsName);
|
||||
await page.getByLabel(/Description courte/i).fill(description);
|
||||
await page.getByLabel(/Auteur/i).fill(author);
|
||||
|
||||
await page.getByRole('button', { name: /^Créer$/i }).click();
|
||||
|
||||
// Redirection vers la liste apres creation.
|
||||
await expect(page).toHaveURL(/\/game-systems$/);
|
||||
// Et la carte du nouveau systeme est visible dans la grille.
|
||||
await expect(page.locator('.gs-card', { hasText: gsName })).toBeVisible();
|
||||
|
||||
// Verification API : le systeme est bien persistant.
|
||||
const all = await request.get('/api/game-systems').then((r) => r.json());
|
||||
const created = all.find((gs: { id: string; name: string }) => gs.name === gsName);
|
||||
expect(created).toBeDefined();
|
||||
expect(created.author).toBe(author);
|
||||
createdIds.push(created.id);
|
||||
});
|
||||
|
||||
test('submit button is disabled when name is empty', async ({ page }) => {
|
||||
await page.goto('/game-systems/create');
|
||||
|
||||
const submit = page.getByRole('button', { name: /^Créer$/i });
|
||||
await expect(submit).toBeDisabled();
|
||||
|
||||
await page.getByLabel(/^Nom/i).fill('Quelque chose');
|
||||
await expect(submit).toBeEnabled();
|
||||
|
||||
await page.getByLabel(/^Nom/i).fill(' ');
|
||||
await expect(submit).toBeDisabled();
|
||||
});
|
||||
|
||||
test('cancel returns to the list without creating', async ({ page, request }) => {
|
||||
const abandoned = `Système abandonné ${Date.now()}`;
|
||||
|
||||
await page.goto('/game-systems/create');
|
||||
await page.getByLabel(/^Nom/i).fill(abandoned);
|
||||
|
||||
await page.getByRole('button', { name: /^Annuler$/i }).click();
|
||||
await expect(page).toHaveURL(/\/game-systems$/);
|
||||
|
||||
// Rien n'a ete cree cote API.
|
||||
const all = await request.get('/api/game-systems').then((r) => r.json());
|
||||
expect(all.find((gs: { name: string }) => gs.name === abandoned)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
61
web/e2e/tests/game-system/game-system-delete.spec.ts
Normal file
61
web/e2e/tests/game-system/game-system-delete.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
seedGameSystem,
|
||||
deleteGameSystem,
|
||||
type SeededGameSystem,
|
||||
} from '../../fixtures/api';
|
||||
|
||||
test.describe('GameSystem delete', () => {
|
||||
let gs: SeededGameSystem;
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
gs = await seedGameSystem(request);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
// Best-effort cleanup — ne fait rien si deja supprime par le test.
|
||||
if (gs?.id) await deleteGameSystem(request, gs.id);
|
||||
});
|
||||
|
||||
test('deletes a game system after confirming and removes it from the list', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await page.goto('/game-systems');
|
||||
|
||||
const card = page.locator('.gs-card', { hasText: gs.name });
|
||||
await expect(card).toBeVisible();
|
||||
|
||||
// Bouton corbeille dans le coin de la carte du systeme seede.
|
||||
await card.locator('.icon-btn').click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog).toContainText(gs.name);
|
||||
|
||||
await dialog.getByRole('button', { name: /^Supprimer$/i }).click();
|
||||
|
||||
// La carte disparait apres reload de la liste.
|
||||
await expect(page.locator('.gs-card', { hasText: gs.name })).toHaveCount(0);
|
||||
|
||||
const res = await request.get(`/api/game-systems/${gs.id}`);
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('keeps the game system when cancel is clicked', async ({ page, request }) => {
|
||||
await page.goto('/game-systems');
|
||||
|
||||
const card = page.locator('.gs-card', { hasText: gs.name });
|
||||
await expect(card).toBeVisible();
|
||||
await card.locator('.icon-btn').click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('button', { name: /^Annuler$/i }).click();
|
||||
|
||||
// La carte est toujours la, le systeme est toujours en base.
|
||||
await expect(page.locator('.gs-card', { hasText: gs.name })).toBeVisible();
|
||||
const res = await request.get(`/api/game-systems/${gs.id}`);
|
||||
expect(res.ok()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
68
web/e2e/tests/game-system/game-system-edit.spec.ts
Normal file
68
web/e2e/tests/game-system/game-system-edit.spec.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
seedGameSystem,
|
||||
deleteGameSystem,
|
||||
getGameSystemById,
|
||||
type SeededGameSystem,
|
||||
} from '../../fixtures/api';
|
||||
|
||||
test.describe('GameSystem edit', () => {
|
||||
let gs: SeededGameSystem;
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
gs = await seedGameSystem(request, {
|
||||
description: 'Description initiale.',
|
||||
author: 'Auteur initial',
|
||||
});
|
||||
});
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
if (gs?.id) await deleteGameSystem(request, gs.id);
|
||||
});
|
||||
|
||||
test('form is prefilled with the game system data', async ({ page }) => {
|
||||
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||
|
||||
await expect(page.getByRole('heading', { name: /Éditer le système/i })).toBeVisible();
|
||||
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||
await expect(page.getByLabel(/Description courte/i)).toHaveValue('Description initiale.');
|
||||
await expect(page.getByLabel(/Auteur/i)).toHaveValue('Auteur initial');
|
||||
});
|
||||
|
||||
test('edits name and description and persists them to API', async ({ page, request }) => {
|
||||
const newName = `${gs.name} renamed`;
|
||||
const newDescription = 'Description mise à jour par le test.';
|
||||
|
||||
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||
|
||||
// Attente que le formulaire soit prerempli avant de fill — sinon le load
|
||||
// async ecrase les valeurs filled (cf. bug arc-edit corrige).
|
||||
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||
|
||||
await page.getByLabel(/^Nom/i).fill(newName);
|
||||
await page.getByLabel(/Description courte/i).fill(newDescription);
|
||||
|
||||
await page.getByRole('button', { name: /^Enregistrer$/i }).click();
|
||||
|
||||
// Retour a la liste apres save.
|
||||
await expect(page).toHaveURL(/\/game-systems$/);
|
||||
|
||||
const persisted = await getGameSystemById(request, gs.id);
|
||||
expect(persisted.name).toBe(newName);
|
||||
expect(persisted.description).toBe(newDescription);
|
||||
});
|
||||
|
||||
test('save button is disabled when name is cleared', async ({ page }) => {
|
||||
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||
|
||||
const nameField = page.getByLabel(/^Nom/i);
|
||||
const saveBtn = page.getByRole('button', { name: /^Enregistrer$/i });
|
||||
|
||||
await expect(saveBtn).toBeEnabled();
|
||||
await nameField.fill('');
|
||||
await expect(saveBtn).toBeDisabled();
|
||||
await nameField.fill('OK');
|
||||
await expect(saveBtn).toBeEnabled();
|
||||
});
|
||||
});
|
||||
111
web/e2e/tests/game-system/game-system-sections.spec.ts
Normal file
111
web/e2e/tests/game-system/game-system-sections.spec.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
seedGameSystem,
|
||||
deleteGameSystem,
|
||||
type SeededGameSystem,
|
||||
} from '../../fixtures/api';
|
||||
|
||||
test.describe('GameSystem rule sections editor', () => {
|
||||
let gs: SeededGameSystem;
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
// On part d'un GameSystem vide (pas de regles seedees) — chaque test gere
|
||||
// ses propres ajouts pour eviter les couplages.
|
||||
gs = await seedGameSystem(request);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
if (gs?.id) await deleteGameSystem(request, gs.id);
|
||||
});
|
||||
|
||||
test('adds a suggested section, fills it, and persists it', async ({ page, request }) => {
|
||||
const sectionContent = 'Initiative à d20, action+bonus+mouvement, dégâts par dés.';
|
||||
|
||||
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||
// Attendre le chargement du form (nom prerempli).
|
||||
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||
|
||||
// Empty state visible tant qu'aucune section n'est ajoutee.
|
||||
await expect(page.locator('.section-list .empty-hint')).toBeVisible();
|
||||
|
||||
// Ajout via la chip suggeree "Combat".
|
||||
await page.locator('.add-row .chip', { hasText: 'Combat' }).click();
|
||||
|
||||
// Une section-card est apparue avec titre "Combat" prerempli + textarea visible.
|
||||
const card = page.locator('.section-card').first();
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card.locator('.section-title-input')).toHaveValue('Combat');
|
||||
await card.locator('.section-content').fill(sectionContent);
|
||||
|
||||
// Save + retour a la liste.
|
||||
await page.getByRole('button', { name: /^Enregistrer$/i }).click();
|
||||
await expect(page).toHaveURL(/\/game-systems$/);
|
||||
|
||||
// Verification cote API : le markdown contient bien la section + son contenu.
|
||||
const persisted = await request.get(`/api/game-systems/${gs.id}`).then((r) => r.json());
|
||||
expect(persisted.rulesMarkdown).toContain('## Combat');
|
||||
expect(persisted.rulesMarkdown).toContain(sectionContent);
|
||||
});
|
||||
|
||||
test('disables a suggested chip after it has been used', async ({ page }) => {
|
||||
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||
|
||||
const combatChip = page.locator('.add-row .chip', { hasText: 'Combat' });
|
||||
await expect(combatChip).toBeEnabled();
|
||||
|
||||
await combatChip.click();
|
||||
|
||||
// Apres ajout, la chip "Combat" est desactivee (suggestion deja utilisee).
|
||||
await expect(combatChip).toBeDisabled();
|
||||
});
|
||||
|
||||
test('adds a custom blank section via "Autre…" and lets the user name it', async ({ page }) => {
|
||||
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||
|
||||
await page.locator('.add-row .chip-custom', { hasText: /Autre/i }).click();
|
||||
|
||||
// Section vierge ajoutee : titre vide, prete a remplir.
|
||||
const card = page.locator('.section-card').first();
|
||||
await expect(card).toBeVisible();
|
||||
const titleInput = card.locator('.section-title-input');
|
||||
await expect(titleInput).toHaveValue('');
|
||||
await titleInput.fill('Sorts');
|
||||
await expect(titleInput).toHaveValue('Sorts');
|
||||
});
|
||||
|
||||
test('removes a section', async ({ page }) => {
|
||||
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||
|
||||
await page.locator('.add-row .chip', { hasText: 'Combat' }).click();
|
||||
await page.locator('.add-row .chip', { hasText: 'Classes' }).click();
|
||||
|
||||
await expect(page.locator('.section-card')).toHaveCount(2);
|
||||
|
||||
// Supprime la premiere section (Combat).
|
||||
await page.locator('.section-card').first().locator('.btn-remove').click();
|
||||
await expect(page.locator('.section-card')).toHaveCount(1);
|
||||
await expect(page.locator('.section-card').first().locator('.section-title-input')).toHaveValue('Classes');
|
||||
});
|
||||
|
||||
test('collapses and expands a section', async ({ page }) => {
|
||||
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||
|
||||
await page.locator('.add-row .chip', { hasText: 'Combat' }).click();
|
||||
const card = page.locator('.section-card').first();
|
||||
|
||||
// Par defaut deployee : textarea visible.
|
||||
await expect(card.locator('.section-content')).toBeVisible();
|
||||
|
||||
// Clic sur le bouton collapse → textarea masquee.
|
||||
await card.locator('.btn-collapse').click();
|
||||
await expect(card.locator('.section-content')).toHaveCount(0);
|
||||
|
||||
// Re-clic → re-deployee.
|
||||
await card.locator('.btn-collapse').click();
|
||||
await expect(card.locator('.section-content')).toBeVisible();
|
||||
});
|
||||
});
|
||||
151
web/e2e/tests/game-system/game-system-templates.spec.ts
Normal file
151
web/e2e/tests/game-system/game-system-templates.spec.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import {
|
||||
seedGameSystem,
|
||||
deleteGameSystem,
|
||||
type SeededGameSystem,
|
||||
} from '../../fixtures/api';
|
||||
|
||||
/**
|
||||
* Tests du composant <app-template-fields-editor> dans le contexte GameSystem.
|
||||
*
|
||||
* Le composant est instancie DEUX fois sur la page d'edition d'un GameSystem
|
||||
* (une fois pour PJ "characterTemplate", une fois pour PNJ "npcTemplate"), donc
|
||||
* les selecteurs doivent etre scopes a l'instance ciblee. On utilise un helper
|
||||
* `tfe(label)` qui renvoie le locator de l'editeur correspondant au titre.
|
||||
*/
|
||||
test.describe('GameSystem template fields editor (PJ / PNJ)', () => {
|
||||
let gs: SeededGameSystem;
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
gs = await seedGameSystem(request);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
if (gs?.id) await deleteGameSystem(request, gs.id);
|
||||
});
|
||||
|
||||
/** Helper : retourne le locator de l'editeur de templates par son label. */
|
||||
const tfe = (page: Page, label: 'PJ' | 'PNJ') =>
|
||||
page.locator('.tfe').filter({ hasText: `Champs de la fiche ${label}` });
|
||||
|
||||
test('adds a suggested field to the PJ template and persists it', async ({ page, request }) => {
|
||||
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||
|
||||
const pjEditor = tfe(page, 'PJ');
|
||||
await expect(pjEditor).toBeVisible();
|
||||
|
||||
// Ajout de "Histoire" via la chip suggeree.
|
||||
await pjEditor.locator('.tfe-add .chip', { hasText: 'Histoire' }).click();
|
||||
|
||||
// Une row apparait avec le nom prerempli.
|
||||
const row = pjEditor.locator('.tfe-item').first();
|
||||
await expect(row).toBeVisible();
|
||||
await expect(row.locator('.tfe-name')).toHaveValue('Histoire');
|
||||
|
||||
// Save → retour a la liste.
|
||||
await page.getByRole('button', { name: /^Enregistrer$/i }).click();
|
||||
await expect(page).toHaveURL(/\/game-systems$/);
|
||||
|
||||
// Verification API : le champ est bien dans characterTemplate.
|
||||
const persisted = await request.get(`/api/game-systems/${gs.id}`).then((r) => r.json());
|
||||
expect(persisted.characterTemplate).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ name: 'Histoire' })]),
|
||||
);
|
||||
// npcTemplate non touche (toujours vide).
|
||||
expect(persisted.npcTemplate ?? []).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('adds a custom NUMBER field via "Nombre" chip', async ({ page }) => {
|
||||
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||
|
||||
const pjEditor = tfe(page, 'PJ');
|
||||
await pjEditor.locator('.tfe-add .chip-custom', { hasText: 'Nombre' }).click();
|
||||
|
||||
const row = pjEditor.locator('.tfe-item').first();
|
||||
await expect(row).toBeVisible();
|
||||
// Champ vide, nom a remplir, type "NUMBER" pre-selectionne dans le select.
|
||||
await expect(row.locator('.tfe-name')).toHaveValue('');
|
||||
await expect(row.locator('.tfe-type')).toHaveValue('NUMBER');
|
||||
|
||||
await row.locator('.tfe-name').fill('Points de vie');
|
||||
await expect(row.locator('.tfe-name')).toHaveValue('Points de vie');
|
||||
});
|
||||
|
||||
test('PJ and PNJ editors are independent (adding to one does not affect the other)', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||
|
||||
await tfe(page, 'PJ').locator('.tfe-add .chip', { hasText: 'Histoire' }).click();
|
||||
await tfe(page, 'PNJ').locator('.tfe-add .chip', { hasText: 'Motivation' }).click();
|
||||
|
||||
await expect(tfe(page, 'PJ').locator('.tfe-item')).toHaveCount(1);
|
||||
await expect(tfe(page, 'PNJ').locator('.tfe-item')).toHaveCount(1);
|
||||
await expect(tfe(page, 'PJ').locator('.tfe-item').first().locator('.tfe-name')).toHaveValue('Histoire');
|
||||
await expect(tfe(page, 'PNJ').locator('.tfe-item').first().locator('.tfe-name')).toHaveValue('Motivation');
|
||||
|
||||
await page.getByRole('button', { name: /^Enregistrer$/i }).click();
|
||||
await expect(page).toHaveURL(/\/game-systems$/);
|
||||
|
||||
const persisted = await request.get(`/api/game-systems/${gs.id}`).then((r) => r.json());
|
||||
expect(persisted.characterTemplate).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ name: 'Histoire' })]),
|
||||
);
|
||||
expect(persisted.npcTemplate).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ name: 'Motivation' })]),
|
||||
);
|
||||
});
|
||||
|
||||
test('removes a field from the template', async ({ page }) => {
|
||||
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||
|
||||
const pjEditor = tfe(page, 'PJ');
|
||||
await pjEditor.locator('.tfe-add .chip', { hasText: 'Histoire' }).click();
|
||||
await pjEditor.locator('.tfe-add .chip', { hasText: 'Apparence' }).click();
|
||||
|
||||
await expect(pjEditor.locator('.tfe-item')).toHaveCount(2);
|
||||
|
||||
// Supprime le premier champ (Histoire) via son btn-remove.
|
||||
await pjEditor.locator('.tfe-item').first().locator('.btn-remove').click();
|
||||
await expect(pjEditor.locator('.tfe-item')).toHaveCount(1);
|
||||
await expect(pjEditor.locator('.tfe-item').first().locator('.tfe-name')).toHaveValue('Apparence');
|
||||
});
|
||||
|
||||
test('reorders fields with the up arrow button', async ({ page }) => {
|
||||
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||
|
||||
const pjEditor = tfe(page, 'PJ');
|
||||
await pjEditor.locator('.tfe-add .chip', { hasText: 'Histoire' }).click();
|
||||
await pjEditor.locator('.tfe-add .chip', { hasText: 'Apparence' }).click();
|
||||
|
||||
// Ordre initial : Histoire, Apparence.
|
||||
let rows = pjEditor.locator('.tfe-item');
|
||||
await expect(rows.nth(0).locator('.tfe-name')).toHaveValue('Histoire');
|
||||
await expect(rows.nth(1).locator('.tfe-name')).toHaveValue('Apparence');
|
||||
|
||||
// Monte Apparence d'un cran.
|
||||
await rows.nth(1).locator('.btn-arrow').first().click();
|
||||
|
||||
rows = pjEditor.locator('.tfe-item');
|
||||
await expect(rows.nth(0).locator('.tfe-name')).toHaveValue('Apparence');
|
||||
await expect(rows.nth(1).locator('.tfe-name')).toHaveValue('Histoire');
|
||||
});
|
||||
|
||||
test('disables a suggested chip after the field has been added', async ({ page }) => {
|
||||
await page.goto(`/game-systems/${gs.id}/edit`);
|
||||
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
|
||||
|
||||
const pjEditor = tfe(page, 'PJ');
|
||||
const histoireChip = pjEditor.locator('.tfe-add .chip', { hasText: 'Histoire' });
|
||||
|
||||
await expect(histoireChip).toBeEnabled();
|
||||
await histoireChip.click();
|
||||
await expect(histoireChip).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -17,32 +17,31 @@ test.describe('Lore delete', () => {
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
let confirmMessage = '';
|
||||
page.on('dialog', async (dialog) => {
|
||||
confirmMessage = dialog.message();
|
||||
await dialog.accept();
|
||||
});
|
||||
|
||||
await page.goto(`/lore/${seeded.id}`);
|
||||
await page.getByRole('button', { name: /^Supprimer$/i }).click();
|
||||
await page.getByRole('button', { name: /^Supprimer$/i }).first().click();
|
||||
|
||||
// Attente du dialog et du retour sur la liste des lores.
|
||||
await expect(page).toHaveURL(/\/lore$/);
|
||||
expect(confirmMessage).toContain(seeded.name);
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog).toContainText(seeded.name);
|
||||
// Lore contient un dossier seedé : le récapitulatif doit l'indiquer.
|
||||
expect(confirmMessage).toMatch(/1 dossier/i);
|
||||
await expect(dialog).toContainText(/1 dossier/i);
|
||||
|
||||
await dialog.getByRole('button', { name: /^Supprimer$/i }).click();
|
||||
|
||||
// Attente du retour sur la liste des lores.
|
||||
await expect(page).toHaveURL(/\/lore$/);
|
||||
|
||||
const res = await request.get(`/api/lores/${seeded.id}`);
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('keeps the lore when the confirm is dismissed', async ({ page, request }) => {
|
||||
page.on('dialog', async (dialog) => {
|
||||
await dialog.dismiss();
|
||||
});
|
||||
|
||||
await page.goto(`/lore/${seeded.id}`);
|
||||
await page.getByRole('button', { name: /^Supprimer$/i }).click();
|
||||
await page.getByRole('button', { name: /^Supprimer$/i }).first().click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('button', { name: /^Annuler$/i }).click();
|
||||
|
||||
// On reste sur le détail, le titre du lore est toujours visible.
|
||||
await expect(page.locator('.detail-header h1')).toHaveText(seeded.name);
|
||||
|
||||
@@ -32,10 +32,12 @@ test.describe('Page delete', () => {
|
||||
});
|
||||
|
||||
test('deletes the page after accepting confirm', async ({ page, request }) => {
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
|
||||
await page.goto(`/lore/${seeded.id}/pages/${pageEntity.id}/edit`);
|
||||
await page.getByRole('button', { name: /^Supprimer$/i }).click();
|
||||
await page.getByRole('button', { name: /^Supprimer$/i }).first().click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('button', { name: /^Supprimer$/i }).click();
|
||||
|
||||
// Le composant redirige vers la racine du Lore après suppression.
|
||||
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}$`));
|
||||
@@ -45,10 +47,12 @@ test.describe('Page delete', () => {
|
||||
});
|
||||
|
||||
test('keeps the page when confirm is dismissed', async ({ page, request }) => {
|
||||
page.on('dialog', (dialog) => dialog.dismiss());
|
||||
|
||||
await page.goto(`/lore/${seeded.id}/pages/${pageEntity.id}/edit`);
|
||||
await page.getByRole('button', { name: /^Supprimer$/i }).click();
|
||||
await page.getByRole('button', { name: /^Supprimer$/i }).first().click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('button', { name: /^Annuler$/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}/pages/${pageEntity.id}/edit$`));
|
||||
|
||||
|
||||
@@ -25,11 +25,13 @@ test.describe('Template delete', () => {
|
||||
});
|
||||
|
||||
test('deletes the template after accepting confirm', async ({ page, request }) => {
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
|
||||
await page.goto(`/lore/${seeded.id}/templates/${template.id}`);
|
||||
await page.locator('.page-header .btn-danger').click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('button', { name: /^Supprimer$/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}$`));
|
||||
|
||||
const templates = await getTemplatesForLore(request, seeded.id);
|
||||
@@ -37,11 +39,13 @@ test.describe('Template delete', () => {
|
||||
});
|
||||
|
||||
test('keeps the template when confirm is dismissed', async ({ page, request }) => {
|
||||
page.on('dialog', (dialog) => dialog.dismiss());
|
||||
|
||||
await page.goto(`/lore/${seeded.id}/templates/${template.id}`);
|
||||
await page.locator('.page-header .btn-danger').click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('button', { name: /^Annuler$/i }).click();
|
||||
|
||||
// On reste sur l'écran d'édition (l'URL ne change pas).
|
||||
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}/templates/${template.id}$`));
|
||||
|
||||
|
||||
4
web/package-lock.json
generated
4
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "loremind-web",
|
||||
"version": "0.8.3",
|
||||
"version": "0.8.4-beta",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "loremind-web",
|
||||
"version": "0.8.3",
|
||||
"version": "0.8.4-beta",
|
||||
"dependencies": {
|
||||
"@angular/animations": "^17.0.0",
|
||||
"@angular/common": "^17.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "loremind-web",
|
||||
"version": "0.8.3",
|
||||
"version": "0.8.4-beta",
|
||||
"description": "LoreMind Frontend - Angular",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
const baseURL = process.env['E2E_BASE_URL'] || 'http://localhost:8081';
|
||||
// Par defaut on cible le serveur de dev Angular (ng serve) sur :4200 pour les
|
||||
// runs locaux — c'est ce qu'on veut quand on bosse en TDD/dev sur le front.
|
||||
// La CI (.gitea/workflows/e2e.yml) override avec `E2E_BASE_URL=http://web`
|
||||
// pour cibler l'instance Docker dans le reseau du runner. Pour tester
|
||||
// localement contre le container docker-compose, lancer :
|
||||
// E2E_BASE_URL=http://localhost:8081 npm run e2e
|
||||
const baseURL = process.env['E2E_BASE_URL'] || 'http://localhost:4200';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e/tests',
|
||||
|
||||
@@ -18,3 +18,4 @@
|
||||
</div>
|
||||
|
||||
<app-global-search></app-global-search>
|
||||
<app-confirm-dialog-host></app-confirm-dialog-host>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { SidebarComponent } from './sidebar/sidebar.component';
|
||||
import { SecondarySidebarComponent } from './shared/secondary-sidebar/secondary-sidebar.component';
|
||||
import { GlobalSearchComponent } from './shared/global-search/global-search.component';
|
||||
import { UpdateBannerComponent } from './shared/update-banner/update-banner.component';
|
||||
import { ConfirmDialogHostComponent } from './shared/confirm-dialog/confirm-dialog-host.component';
|
||||
import { LayoutService } from './services/layout.service';
|
||||
import { GlobalSearchService } from './services/global-search.service';
|
||||
import { VersionCheckerService } from './services/version-checker.service';
|
||||
@@ -18,6 +19,7 @@ import { VersionCheckerService } from './services/version-checker.service';
|
||||
SecondarySidebarComponent,
|
||||
GlobalSearchComponent,
|
||||
UpdateBannerComponent,
|
||||
ConfirmDialogHostComponent,
|
||||
AsyncPipe,
|
||||
NgIf,
|
||||
],
|
||||
|
||||
@@ -7,9 +7,8 @@ import { LucideAngularModule, BookOpen } from 'lucide-angular';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
||||
import { Campaign } from '../../../services/campaign.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
|
||||
import { LayoutService } from '../../../services/layout.service';
|
||||
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
|
||||
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
||||
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
||||
|
||||
@@ -62,21 +61,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
|
||||
}).subscribe(({ campaign, allCampaigns, treeData }) => {
|
||||
this.existingArcCount = treeData.arcs.length;
|
||||
|
||||
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
|
||||
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
|
||||
}));
|
||||
|
||||
this.layoutService.show({
|
||||
title: campaign.name,
|
||||
items: buildCampaignTree(this.campaignId, treeData),
|
||||
footerLabel: 'Toutes les campagnes',
|
||||
createActions: [
|
||||
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${this.campaignId}/arcs/create` }
|
||||
],
|
||||
globalItems,
|
||||
globalBackLabel: 'Toutes les campagnes',
|
||||
globalBackRoute: '/campaigns'
|
||||
});
|
||||
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -89,7 +74,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
|
||||
order: this.existingArcCount + 1,
|
||||
icon: this.selectedIcon
|
||||
}).subscribe({
|
||||
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', created.id, 'edit']),
|
||||
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', created.id]),
|
||||
error: () => console.error('Erreur lors de la création de l\'arc')
|
||||
});
|
||||
}
|
||||
@@ -99,6 +84,9 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.layoutService.hide();
|
||||
// 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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,16 +9,17 @@ import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { PageService } from '../../../services/page.service';
|
||||
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
||||
import { LayoutService } from '../../../services/layout.service';
|
||||
import { PageTitleService } from '../../../services/page-title.service';
|
||||
import { Campaign, Arc } from '../../../services/campaign.model';
|
||||
import { Arc } from '../../../services/campaign.model';
|
||||
import { Page } from '../../../services/page.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
|
||||
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
|
||||
import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-link-picker.component';
|
||||
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
||||
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
||||
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
||||
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||
|
||||
/**
|
||||
* Écran de détail/modification d'un Arc.
|
||||
@@ -78,7 +79,8 @@ export class ArcEditComponent implements OnInit, OnDestroy {
|
||||
private npcService: NpcService,
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService
|
||||
private pageTitleService: PageTitleService,
|
||||
private confirmDialog: ConfirmDialogService
|
||||
) {
|
||||
this.form = this.fb.group({
|
||||
name: ['', Validators.required],
|
||||
@@ -142,21 +144,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
|
||||
resolution: arc.resolution ?? ''
|
||||
});
|
||||
|
||||
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
|
||||
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
|
||||
}));
|
||||
|
||||
this.layoutService.show({
|
||||
title: campaign.name,
|
||||
items: buildCampaignTree(this.campaignId, treeData),
|
||||
footerLabel: 'Toutes les campagnes',
|
||||
createActions: [
|
||||
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${this.campaignId}/arcs/create` }
|
||||
],
|
||||
globalItems,
|
||||
globalBackLabel: 'Toutes les campagnes',
|
||||
globalBackRoute: '/campaigns'
|
||||
});
|
||||
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -183,11 +171,19 @@ export class ArcEditComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
delete(): void {
|
||||
if (!confirm(`Supprimer l'arc "${this.arc?.name}" ? Cette action est irréversible.`)) return;
|
||||
this.confirmDialog.confirm({
|
||||
title: 'Supprimer l\'arc',
|
||||
message: `Supprimer l'arc "${this.arc?.name}" ?`,
|
||||
details: ['Cette action est irréversible.'],
|
||||
confirmLabel: 'Supprimer',
|
||||
variant: 'danger'
|
||||
}).then(ok => {
|
||||
if (!ok) return;
|
||||
this.campaignService.deleteArc(this.arcId).subscribe({
|
||||
next: () => this.router.navigate(['/campaigns', this.campaignId]),
|
||||
error: () => console.error('Erreur lors de la suppression')
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
@@ -195,6 +191,9 @@ export class ArcEditComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.layoutService.hide();
|
||||
// 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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,13 @@ import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { PageService } from '../../../services/page.service';
|
||||
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
||||
import { LayoutService } from '../../../services/layout.service';
|
||||
import { PageTitleService } from '../../../services/page-title.service';
|
||||
import { Campaign, Arc } from '../../../services/campaign.model';
|
||||
import { Arc } from '../../../services/campaign.model';
|
||||
import { Page } from '../../../services/page.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
|
||||
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
|
||||
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
||||
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||
|
||||
/**
|
||||
* Écran de consultation d'un Arc narratif (lecture seule).
|
||||
@@ -50,7 +51,8 @@ export class ArcViewComponent implements OnInit, OnDestroy {
|
||||
private npcService: NpcService,
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService
|
||||
private pageTitleService: PageTitleService,
|
||||
private confirmDialog: ConfirmDialogService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -83,20 +85,7 @@ export class ArcViewComponent implements OnInit, OnDestroy {
|
||||
this.availablePages = pages;
|
||||
this.pageTitleService.set(arc.name);
|
||||
|
||||
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
|
||||
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
|
||||
}));
|
||||
this.layoutService.show({
|
||||
title: campaign.name,
|
||||
items: buildCampaignTree(this.campaignId, treeData),
|
||||
footerLabel: 'Toutes les campagnes',
|
||||
createActions: [
|
||||
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${this.campaignId}/arcs/create` }
|
||||
],
|
||||
globalItems,
|
||||
globalBackLabel: 'Toutes les campagnes',
|
||||
globalBackRoute: '/campaigns'
|
||||
});
|
||||
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -122,25 +111,34 @@ export class ArcViewComponent implements OnInit, OnDestroy {
|
||||
if (impact.chapters > 0) parts.push(`${impact.chapters} chapitre${impact.chapters > 1 ? 's' : ''}`);
|
||||
if (impact.scenes > 0) parts.push(`${impact.scenes} scène${impact.scenes > 1 ? 's' : ''}`);
|
||||
|
||||
const lines = [`Supprimer l'arc "${arc.name}" ?`];
|
||||
const details: string[] = [];
|
||||
if (parts.length) {
|
||||
lines.push('');
|
||||
lines.push(`Cette action supprimera aussi : ${parts.join(', ')}.`);
|
||||
details.push(`Cette action supprimera aussi : ${parts.join(', ')}.`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('Cette action est irréversible.');
|
||||
details.push('Cette action est irréversible.');
|
||||
|
||||
if (!confirm(lines.join('\n'))) return;
|
||||
this.confirmDialog.confirm({
|
||||
title: 'Supprimer l\'arc',
|
||||
message: `Supprimer l'arc "${arc.name}" ?`,
|
||||
details,
|
||||
confirmLabel: 'Supprimer',
|
||||
variant: 'danger'
|
||||
}).then(ok => {
|
||||
if (!ok) return;
|
||||
this.campaignService.deleteArc(arc.id!).subscribe({
|
||||
next: () => this.router.navigate(['/campaigns', this.campaignId]),
|
||||
error: () => console.error('Erreur lors de la suppression de l\'arc')
|
||||
});
|
||||
});
|
||||
},
|
||||
error: () => console.error('Impossible de récupérer les dépendances de l\'arc')
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.layoutService.hide();
|
||||
// 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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ import { switchMap, map } from 'rxjs/operators';
|
||||
import { CampaignService } from '../services/campaign.service';
|
||||
import { CharacterService } from '../services/character.service';
|
||||
import { NpcService } from '../services/npc.service';
|
||||
import { TreeItem } from '../services/layout.service';
|
||||
import { Arc, Chapter, Scene } from '../services/campaign.model';
|
||||
import { TreeItem, SecondarySidebarConfig, GlobalItem } from '../services/layout.service';
|
||||
import { Arc, Chapter, Scene, Campaign } from '../services/campaign.model';
|
||||
import { Character } from '../services/character.model';
|
||||
import { Npc } from '../services/npc.model';
|
||||
|
||||
@@ -83,7 +83,7 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
|
||||
const characterItems: TreeItem[] = sortedCharacters.map(ch => ({
|
||||
id: `character-${ch.id}`,
|
||||
label: ch.name,
|
||||
route: `/campaigns/${campaignId}/characters/${ch.id}/edit`
|
||||
route: `/campaigns/${campaignId}/characters/${ch.id}`
|
||||
}));
|
||||
|
||||
const charactersNode: TreeItem = {
|
||||
@@ -107,7 +107,7 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
|
||||
const npcItems: TreeItem[] = sortedNpcs.map(n => ({
|
||||
id: `npc-${n.id}`,
|
||||
label: n.name,
|
||||
route: `/campaigns/${campaignId}/npcs/${n.id}/edit`
|
||||
route: `/campaigns/${campaignId}/npcs/${n.id}`
|
||||
}));
|
||||
|
||||
const npcsNode: TreeItem = {
|
||||
@@ -172,3 +172,35 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
|
||||
|
||||
return [...arcNodes, charactersNode, npcsNode];
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit la SecondarySidebarConfig complete d'une campagne a partir des
|
||||
* donnees deja chargees. A utiliser quand le composant fait deja un forkJoin
|
||||
* pour ses propres donnees (arc-view, scene-edit, etc.) et a deja `campaign`,
|
||||
* `allCampaigns` et `treeData` en main — evite de refaire les memes HTTP.
|
||||
*
|
||||
* Pour les composants qui n'ont pas d'autre fetch a faire (character-view,
|
||||
* npc-view...), preferer CampaignSidebarService.show(campaignId) qui orchestre
|
||||
* le forkJoin et appelle layoutService.show() en une seule ligne.
|
||||
*/
|
||||
export function buildCampaignSidebarConfig(
|
||||
campaign: Campaign,
|
||||
allCampaigns: Campaign[],
|
||||
treeData: CampaignTreeData,
|
||||
campaignId: string
|
||||
): SecondarySidebarConfig {
|
||||
const globalItems: GlobalItem[] = allCampaigns.map(c => ({
|
||||
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
|
||||
}));
|
||||
return {
|
||||
title: campaign.name,
|
||||
items: buildCampaignTree(campaignId, treeData),
|
||||
footerLabel: 'Toutes les campagnes',
|
||||
createActions: [
|
||||
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${campaignId}/arcs/create` }
|
||||
],
|
||||
globalItems,
|
||||
globalBackLabel: 'Toutes les campagnes',
|
||||
globalBackRoute: '/campaigns'
|
||||
};
|
||||
}
|
||||
|
||||
@@ -50,14 +50,50 @@
|
||||
|
||||
<div class="field">
|
||||
<label for="campaign-game-system">Système de JDR</label>
|
||||
<select id="campaign-game-system" formControlName="gameSystemId">
|
||||
<select *ngIf="!creatingGameSystem" id="campaign-game-system" formControlName="gameSystemId">
|
||||
<option value="">— Aucun (campagne générique) —</option>
|
||||
<option *ngFor="let gs of availableGameSystems" [value]="gs.id">{{ gs.name }}</option>
|
||||
<option [value]="CREATE_GAMESYSTEM_SENTINEL">+ Créer un nouveau système…</option>
|
||||
</select>
|
||||
|
||||
<!-- Mode creation inline : remplace temporairement le select. -->
|
||||
<div *ngIf="creatingGameSystem" class="inline-create">
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="newGameSystemName"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
placeholder="Nom du nouveau système (ex: D&D 5e, Nimble, Maison)"
|
||||
(keydown.enter)="$event.preventDefault(); submitCreateGameSystem()"
|
||||
(keydown.escape)="cancelCreateGameSystem()"
|
||||
autofocus
|
||||
/>
|
||||
<div class="inline-create-actions">
|
||||
<button type="button" class="btn-inline-primary"
|
||||
[disabled]="!newGameSystemName.trim() || creatingGameSystemInFlight"
|
||||
(click)="submitCreateGameSystem()">
|
||||
<lucide-icon [img]="Check" [size]="14"></lucide-icon>
|
||||
Créer
|
||||
</button>
|
||||
<button type="button" class="btn-inline-secondary" (click)="cancelCreateGameSystem()">
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
<p class="hint">
|
||||
Création rapide — vous pourrez ajouter les règles, les templates de fiches PJ/PNJ
|
||||
et le reste depuis la section "Systèmes" plus tard.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p *ngIf="!creatingGameSystem" class="hint">
|
||||
Optionnel. Si défini, l'IA injectera les règles du système (classes, combat, lore...)
|
||||
dans ses suggestions pour respecter les mécaniques du JDR.
|
||||
</p>
|
||||
<p *ngIf="!creatingGameSystem" class="hint hint-warning">
|
||||
⚠️ Le système de jeu choisi détermine aussi le <strong>template des fiches de PJ et PNJ</strong>.
|
||||
Le changer plus tard rendra les champs des fiches existantes invisibles
|
||||
(les données restent stockées mais ne s'afficheront qu'en revenant à l'ancien système).
|
||||
Choisissez bien dès le départ si possible.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
|
||||
@@ -87,6 +87,81 @@ form {
|
||||
input[type="number"] { width: 120px; }
|
||||
}
|
||||
|
||||
.inline-create {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.875rem;
|
||||
background: #1a2233;
|
||||
border: 1px solid #2d3748;
|
||||
border-left: 3px solid #6c63ff;
|
||||
border-radius: 8px;
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
background: #1f2937;
|
||||
border: 1px solid #374151;
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem 0.875rem;
|
||||
color: white;
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&::placeholder { color: #4b5563; }
|
||||
&:focus { border-color: #6c63ff; }
|
||||
}
|
||||
}
|
||||
|
||||
.inline-create-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-inline-primary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.875rem;
|
||||
background: #6c63ff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover:not(:disabled) { background: #5b52e0; }
|
||||
&:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
.btn-inline-secondary {
|
||||
padding: 0.5rem 0.875rem;
|
||||
background: transparent;
|
||||
color: #9ca3af;
|
||||
border: 1px solid #374151;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
|
||||
&:hover { background: #1f2937; color: white; }
|
||||
}
|
||||
|
||||
.hint-warning {
|
||||
margin-top: 0.5rem;
|
||||
background: rgba(234, 179, 8, 0.08);
|
||||
border-left: 3px solid #eab308;
|
||||
border-radius: 4px;
|
||||
padding: 0.625rem 0.875rem;
|
||||
color: #fbbf24;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
|
||||
strong { color: #fde68a; }
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: #1f2937;
|
||||
border-radius: 8px;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Component, EventEmitter, OnInit, Output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { LucideAngularModule, BookCopy, X } from 'lucide-angular';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { LucideAngularModule, BookCopy, X, Plus, Check } from 'lucide-angular';
|
||||
import { LoreService } from '../../../services/lore.service';
|
||||
import { Lore } from '../../../services/lore.model';
|
||||
import { GameSystemService } from '../../../services/game-system.service';
|
||||
@@ -22,7 +23,7 @@ export interface CampaignCreatePayload {
|
||||
@Component({
|
||||
selector: 'app-campaign-create',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule],
|
||||
imports: [CommonModule, ReactiveFormsModule, FormsModule, LucideAngularModule],
|
||||
templateUrl: './campaign-create.component.html',
|
||||
styleUrls: ['./campaign-create.component.scss']
|
||||
})
|
||||
@@ -32,6 +33,11 @@ export class CampaignCreateComponent implements OnInit {
|
||||
|
||||
readonly BookCopy = BookCopy;
|
||||
readonly X = X;
|
||||
readonly Plus = Plus;
|
||||
readonly Check = Check;
|
||||
|
||||
/** Valeur sentinelle de l'option "Creer un systeme" dans le <select>. */
|
||||
readonly CREATE_GAMESYSTEM_SENTINEL = '__create__';
|
||||
|
||||
form: FormGroup;
|
||||
/** Lores disponibles pour association. Chargés à l'ouverture de la modal. */
|
||||
@@ -39,6 +45,11 @@ export class CampaignCreateComponent implements OnInit {
|
||||
/** GameSystems disponibles pour association. */
|
||||
availableGameSystems: GameSystem[] = [];
|
||||
|
||||
/** Mode creation inline d'un GameSystem depuis le dropdown. */
|
||||
creatingGameSystem = false;
|
||||
newGameSystemName = '';
|
||||
creatingGameSystemInFlight = false;
|
||||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
private loreService: LoreService,
|
||||
@@ -62,6 +73,47 @@ export class CampaignCreateComponent implements OnInit {
|
||||
next: (gs) => this.availableGameSystems = gs,
|
||||
error: () => this.availableGameSystems = []
|
||||
});
|
||||
|
||||
// Detecte la selection de l'option sentinelle "Creer un systeme" et bascule
|
||||
// en mode creation inline. On reinitialise immediatement le control a ''
|
||||
// pour que la sentinelle ne reste pas en valeur reelle du form.
|
||||
this.form.get('gameSystemId')?.valueChanges.subscribe(value => {
|
||||
if (value === this.CREATE_GAMESYSTEM_SENTINEL) {
|
||||
this.form.get('gameSystemId')?.setValue('', { emitEvent: false });
|
||||
this.startCreateGameSystem();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
startCreateGameSystem(): void {
|
||||
this.creatingGameSystem = true;
|
||||
this.newGameSystemName = '';
|
||||
}
|
||||
|
||||
cancelCreateGameSystem(): void {
|
||||
this.creatingGameSystem = false;
|
||||
this.newGameSystemName = '';
|
||||
}
|
||||
|
||||
submitCreateGameSystem(): void {
|
||||
const name = this.newGameSystemName.trim();
|
||||
if (!name || this.creatingGameSystemInFlight) return;
|
||||
this.creatingGameSystemInFlight = true;
|
||||
this.gameSystemService.create({ name, isPublic: false }).subscribe({
|
||||
next: (created) => {
|
||||
this.creatingGameSystemInFlight = false;
|
||||
this.availableGameSystems = [...this.availableGameSystems, created];
|
||||
if (created.id) {
|
||||
this.form.get('gameSystemId')?.setValue(created.id);
|
||||
}
|
||||
this.creatingGameSystem = false;
|
||||
this.newGameSystemName = '';
|
||||
},
|
||||
error: () => {
|
||||
this.creatingGameSystemInFlight = false;
|
||||
console.error('Erreur lors de la creation du systeme de jeu');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
|
||||
@@ -55,10 +55,37 @@
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Système de JDR</label>
|
||||
<select [(ngModel)]="editGameSystemId" name="editGameSystemId">
|
||||
<select *ngIf="!creatingGameSystem"
|
||||
[(ngModel)]="editGameSystemId"
|
||||
name="editGameSystemId"
|
||||
(ngModelChange)="onEditGameSystemChange($event)">
|
||||
<option value="">— Aucun (générique) —</option>
|
||||
<option *ngFor="let gs of availableGameSystems" [value]="gs.id">{{ gs.name }}</option>
|
||||
<option [value]="CREATE_GAMESYSTEM_SENTINEL">+ Créer un nouveau système…</option>
|
||||
</select>
|
||||
|
||||
<div *ngIf="creatingGameSystem" class="inline-create">
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="newGameSystemName"
|
||||
name="newGameSystemName"
|
||||
placeholder="Nom du nouveau système (ex: D&D 5e, Nimble, Maison)"
|
||||
(keydown.enter)="$event.preventDefault(); submitCreateGameSystem()"
|
||||
(keydown.escape)="cancelCreateGameSystem()"
|
||||
autofocus
|
||||
/>
|
||||
<div class="inline-create-actions">
|
||||
<button type="button" class="btn-inline-primary"
|
||||
[disabled]="!newGameSystemName.trim() || creatingGameSystemInFlight"
|
||||
(click)="submitCreateGameSystem()">
|
||||
<lucide-icon [img]="Check" [size]="14"></lucide-icon>
|
||||
Créer
|
||||
</button>
|
||||
<button type="button" class="btn-inline-secondary" (click)="cancelCreateGameSystem()">
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="btn-primary" (click)="saveEdit()" [disabled]="!editName.trim()">
|
||||
|
||||
@@ -122,6 +122,64 @@
|
||||
textarea { resize: vertical; }
|
||||
}
|
||||
|
||||
.inline-create {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.875rem;
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
border-left: 3px solid #6c63ff;
|
||||
border-radius: 8px;
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
background: #0a1320;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
color: white;
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
|
||||
&::placeholder { color: #4b5563; }
|
||||
&:focus { border-color: #6c63ff; }
|
||||
}
|
||||
|
||||
.inline-create-actions { display: flex; gap: 0.5rem; }
|
||||
|
||||
.btn-inline-primary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.45rem 0.875rem;
|
||||
background: #6c63ff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover:not(:disabled) { background: #5b52e0; }
|
||||
&:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
.btn-inline-secondary {
|
||||
padding: 0.45rem 0.875rem;
|
||||
background: transparent;
|
||||
color: #9ca3af;
|
||||
border: 1px solid #374151;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
|
||||
&:hover { background: #1f2937; color: white; }
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions { justify-content: flex-end; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, User, Dices, Drama } from 'lucide-angular';
|
||||
import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, User, Dices, Drama, Check } from 'lucide-angular';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { catchError, switchMap, filter, map } from 'rxjs/operators';
|
||||
@@ -14,11 +14,12 @@ import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { Character } from '../../../services/character.model';
|
||||
import { Npc } from '../../../services/npc.model';
|
||||
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
||||
import { LayoutService } from '../../../services/layout.service';
|
||||
import { PageTitleService } from '../../../services/page-title.service';
|
||||
import { Campaign, Arc } from '../../../services/campaign.model';
|
||||
import { Lore } from '../../../services/lore.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree, CampaignTreeData } from '../../campaign-tree.helper';
|
||||
import { loadCampaignTreeData, buildCampaignSidebarConfig, CampaignTreeData } from '../../campaign-tree.helper';
|
||||
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-campaign-detail',
|
||||
@@ -36,6 +37,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
readonly User = User;
|
||||
readonly Dices = Dices;
|
||||
readonly Drama = Drama;
|
||||
readonly Check = Check;
|
||||
|
||||
campaign: Campaign | null = null;
|
||||
arcs: Arc[] = [];
|
||||
@@ -61,6 +63,13 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
editLoreId = '';
|
||||
editGameSystemId = '';
|
||||
|
||||
/** Valeur sentinelle de l'option "Creer un systeme" dans le <select>. */
|
||||
readonly CREATE_GAMESYSTEM_SENTINEL = '__create__';
|
||||
/** Mode creation inline d'un GameSystem depuis le dropdown d'edition. */
|
||||
creatingGameSystem = false;
|
||||
newGameSystemName = '';
|
||||
creatingGameSystemInFlight = false;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
@@ -70,7 +79,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService
|
||||
private pageTitleService: PageTitleService,
|
||||
private confirmDialog: ConfirmDialogService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -241,24 +251,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
private showLayout(allCampaigns: Campaign[], data: CampaignTreeData): void {
|
||||
const campaignId = this.campaign!.id!;
|
||||
const globalItems: GlobalItem[] = allCampaigns.map(c => ({
|
||||
id: c.id!,
|
||||
name: c.name,
|
||||
route: `/campaigns/${c.id}`
|
||||
}));
|
||||
|
||||
this.layoutService.show({
|
||||
title: this.campaign!.name,
|
||||
items: buildCampaignTree(campaignId, data),
|
||||
footerLabel: 'Toutes les campagnes',
|
||||
createActions: [
|
||||
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${campaignId}/arcs/create` }
|
||||
],
|
||||
globalItems,
|
||||
globalBackLabel: 'Toutes les campagnes',
|
||||
globalBackRoute: '/campaigns'
|
||||
});
|
||||
this.layoutService.show(buildCampaignSidebarConfig(this.campaign!, allCampaigns, data, this.campaign!.id!));
|
||||
}
|
||||
|
||||
// ─────────────── Édition / suppression de la Campagne ───────────────
|
||||
@@ -283,16 +276,83 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
|
||||
cancelEdit(): void {
|
||||
this.editing = false;
|
||||
this.creatingGameSystem = false;
|
||||
this.newGameSystemName = '';
|
||||
}
|
||||
|
||||
/** Detecte la selection de l'option sentinelle dans le <select> GameSystem. */
|
||||
onEditGameSystemChange(value: string): void {
|
||||
if (value === this.CREATE_GAMESYSTEM_SENTINEL) {
|
||||
this.editGameSystemId = '';
|
||||
this.startCreateGameSystem();
|
||||
}
|
||||
}
|
||||
|
||||
startCreateGameSystem(): void {
|
||||
this.creatingGameSystem = true;
|
||||
this.newGameSystemName = '';
|
||||
}
|
||||
|
||||
cancelCreateGameSystem(): void {
|
||||
this.creatingGameSystem = false;
|
||||
this.newGameSystemName = '';
|
||||
}
|
||||
|
||||
submitCreateGameSystem(): void {
|
||||
const name = this.newGameSystemName.trim();
|
||||
if (!name || this.creatingGameSystemInFlight) return;
|
||||
this.creatingGameSystemInFlight = true;
|
||||
this.gameSystemService.create({ name, isPublic: false }).subscribe({
|
||||
next: (created) => {
|
||||
this.creatingGameSystemInFlight = false;
|
||||
this.availableGameSystems = [...this.availableGameSystems, created];
|
||||
if (created.id) {
|
||||
this.editGameSystemId = created.id;
|
||||
}
|
||||
this.creatingGameSystem = false;
|
||||
this.newGameSystemName = '';
|
||||
},
|
||||
error: () => {
|
||||
this.creatingGameSystemInFlight = false;
|
||||
console.error('Erreur lors de la creation du systeme de jeu');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
saveEdit(): void {
|
||||
if (!this.campaign || !this.editName.trim()) return;
|
||||
const newGameSystemId = this.editGameSystemId ? this.editGameSystemId : null;
|
||||
const currentGameSystemId = this.campaign.gameSystemId ?? null;
|
||||
const gameSystemChanged = newGameSystemId !== currentGameSystemId;
|
||||
const hasSheets = this.characters.length > 0 || this.npcs.length > 0;
|
||||
if (gameSystemChanged && hasSheets) {
|
||||
const count = this.characters.length + this.npcs.length;
|
||||
this.confirmDialog.confirm({
|
||||
title: 'Changer le systeme de jeu ?',
|
||||
message:
|
||||
`Vous etes sur le point de changer le systeme de jeu de cette campagne. ` +
|
||||
`Cela change egalement le template des fiches de PJ et PNJ.`,
|
||||
details: [
|
||||
`${count} fiche(s) existante(s) sont liees au template du systeme actuel.`,
|
||||
`Leurs champs ne s'afficheront plus avec le nouveau systeme.`,
|
||||
`Les donnees restent stockees : revenir a l'ancien systeme les rendra a nouveau visibles.`
|
||||
],
|
||||
confirmLabel: 'Changer quand meme',
|
||||
variant: 'warning'
|
||||
}).then(ok => { if (ok) this.persistEdit(newGameSystemId); });
|
||||
return;
|
||||
}
|
||||
this.persistEdit(newGameSystemId);
|
||||
}
|
||||
|
||||
private persistEdit(newGameSystemId: string | null): void {
|
||||
if (!this.campaign) return;
|
||||
this.campaignService.updateCampaign(this.campaign.id!, {
|
||||
name: this.editName.trim(),
|
||||
description: this.editDescription,
|
||||
playerCount: this.campaign.playerCount ?? 0,
|
||||
loreId: this.editLoreId ? this.editLoreId : null,
|
||||
gameSystemId: this.editGameSystemId ? this.editGameSystemId : null
|
||||
gameSystemId: newGameSystemId
|
||||
}).subscribe({
|
||||
next: (updated) => {
|
||||
this.campaign = updated;
|
||||
@@ -321,19 +381,23 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
if (impact.scenes > 0) parts.push(`${impact.scenes} scène${impact.scenes > 1 ? 's' : ''}`);
|
||||
if (impact.characters > 0) parts.push(`${impact.characters} personnage${impact.characters > 1 ? 's' : ''}`);
|
||||
|
||||
const lines = [`Supprimer définitivement la campagne "${campaign.name}" ?`];
|
||||
if (parts.length) {
|
||||
lines.push('');
|
||||
lines.push(`Cette action supprimera aussi : ${parts.join(', ')}.`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('Cette action est irréversible.');
|
||||
const details: string[] = [];
|
||||
if (parts.length) details.push(`Sera aussi supprime : ${parts.join(', ')}.`);
|
||||
details.push('Cette action est irreversible.');
|
||||
|
||||
if (!confirm(lines.join('\n'))) return;
|
||||
this.confirmDialog.confirm({
|
||||
title: 'Supprimer la campagne ?',
|
||||
message: `Supprimer definitivement la campagne "${campaign.name}" ?`,
|
||||
details,
|
||||
confirmLabel: 'Supprimer',
|
||||
variant: 'danger'
|
||||
}).then(ok => {
|
||||
if (!ok) return;
|
||||
this.campaignService.deleteCampaign(campaign.id!).subscribe({
|
||||
next: () => this.router.navigate(['/campaigns']),
|
||||
error: () => console.error('Erreur lors de la suppression de la campagne')
|
||||
});
|
||||
});
|
||||
},
|
||||
error: () => console.error('Impossible de récupérer les dépendances de la campagne')
|
||||
});
|
||||
|
||||
@@ -7,9 +7,8 @@ import { LucideAngularModule } from 'lucide-angular';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
||||
import { Campaign } from '../../../services/campaign.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
|
||||
import { LayoutService } from '../../../services/layout.service';
|
||||
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
|
||||
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
||||
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
||||
|
||||
@@ -65,21 +64,7 @@ export class ChapterCreateComponent implements OnInit, OnDestroy {
|
||||
this.arcName = currentArc?.name ?? '';
|
||||
this.existingChapterCount = treeData.chaptersByArc[this.arcId]?.length ?? 0;
|
||||
|
||||
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
|
||||
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
|
||||
}));
|
||||
|
||||
this.layoutService.show({
|
||||
title: campaign.name,
|
||||
items: buildCampaignTree(this.campaignId, treeData),
|
||||
footerLabel: 'Toutes les campagnes',
|
||||
createActions: [
|
||||
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${this.campaignId}/arcs/create` }
|
||||
],
|
||||
globalItems,
|
||||
globalBackLabel: 'Toutes les campagnes',
|
||||
globalBackRoute: '/campaigns'
|
||||
});
|
||||
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -102,6 +87,9 @@ export class ChapterCreateComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.layoutService.hide();
|
||||
// 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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,16 +9,17 @@ import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { PageService } from '../../../services/page.service';
|
||||
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
||||
import { LayoutService } from '../../../services/layout.service';
|
||||
import { PageTitleService } from '../../../services/page-title.service';
|
||||
import { Campaign, Chapter } from '../../../services/campaign.model';
|
||||
import { Chapter } from '../../../services/campaign.model';
|
||||
import { Page } from '../../../services/page.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
|
||||
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
|
||||
import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-link-picker.component';
|
||||
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
||||
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
||||
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
||||
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||
|
||||
/**
|
||||
* Écran de détail/modification d'un Chapitre.
|
||||
@@ -71,7 +72,8 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
||||
private npcService: NpcService,
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService
|
||||
private pageTitleService: PageTitleService,
|
||||
private confirmDialog: ConfirmDialogService
|
||||
) {
|
||||
this.form = this.fb.group({
|
||||
name: ['', Validators.required],
|
||||
@@ -130,21 +132,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
||||
narrativeStakes: chapter.narrativeStakes ?? ''
|
||||
});
|
||||
|
||||
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
|
||||
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
|
||||
}));
|
||||
|
||||
this.layoutService.show({
|
||||
title: campaign.name,
|
||||
items: buildCampaignTree(this.campaignId, treeData),
|
||||
footerLabel: 'Toutes les campagnes',
|
||||
createActions: [
|
||||
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${this.campaignId}/arcs/create` }
|
||||
],
|
||||
globalItems,
|
||||
globalBackLabel: 'Toutes les campagnes',
|
||||
globalBackRoute: '/campaigns'
|
||||
});
|
||||
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -169,11 +157,19 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
delete(): void {
|
||||
if (!confirm(`Supprimer le chapitre "${this.chapter?.name}" ? Cette action est irréversible.`)) return;
|
||||
this.confirmDialog.confirm({
|
||||
title: 'Supprimer le chapitre',
|
||||
message: `Supprimer le chapitre "${this.chapter?.name}" ?`,
|
||||
details: ['Cette action est irréversible.'],
|
||||
confirmLabel: 'Supprimer',
|
||||
variant: 'danger'
|
||||
}).then(ok => {
|
||||
if (!ok) return;
|
||||
this.campaignService.deleteChapter(this.chapterId).subscribe({
|
||||
next: () => this.router.navigate(['/campaigns', this.campaignId]),
|
||||
error: () => console.error('Erreur lors de la suppression')
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
@@ -181,6 +177,9 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.layoutService.hide();
|
||||
// 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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -371,6 +371,9 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.layoutService.hide();
|
||||
// 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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,13 @@ import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { PageService } from '../../../services/page.service';
|
||||
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
||||
import { LayoutService } from '../../../services/layout.service';
|
||||
import { PageTitleService } from '../../../services/page-title.service';
|
||||
import { Campaign, Chapter } from '../../../services/campaign.model';
|
||||
import { Chapter } from '../../../services/campaign.model';
|
||||
import { Page } from '../../../services/page.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
|
||||
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
|
||||
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
||||
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||
|
||||
/**
|
||||
* Écran de consultation d'un Chapitre (lecture seule).
|
||||
@@ -49,7 +50,8 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
|
||||
private npcService: NpcService,
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService
|
||||
private pageTitleService: PageTitleService,
|
||||
private confirmDialog: ConfirmDialogService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -86,20 +88,7 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
|
||||
this.availablePages = pages;
|
||||
this.pageTitleService.set(chapter.name);
|
||||
|
||||
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
|
||||
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
|
||||
}));
|
||||
this.layoutService.show({
|
||||
title: campaign.name,
|
||||
items: buildCampaignTree(this.campaignId, treeData),
|
||||
footerLabel: 'Toutes les campagnes',
|
||||
createActions: [
|
||||
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${this.campaignId}/arcs/create` }
|
||||
],
|
||||
globalItems,
|
||||
globalBackLabel: 'Toutes les campagnes',
|
||||
globalBackRoute: '/campaigns'
|
||||
});
|
||||
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -128,25 +117,34 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
|
||||
const chapter = this.chapter;
|
||||
this.campaignService.getChapterDeletionImpact(chapter.id!).subscribe({
|
||||
next: impact => {
|
||||
const lines = [`Supprimer le chapitre "${chapter.name}" ?`];
|
||||
const details: string[] = [];
|
||||
if (impact.scenes > 0) {
|
||||
lines.push('');
|
||||
lines.push(`Cette action supprimera aussi : ${impact.scenes} scène${impact.scenes > 1 ? 's' : ''}.`);
|
||||
details.push(`Cette action supprimera aussi : ${impact.scenes} scène${impact.scenes > 1 ? 's' : ''}.`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('Cette action est irréversible.');
|
||||
details.push('Cette action est irréversible.');
|
||||
|
||||
if (!confirm(lines.join('\n'))) return;
|
||||
this.confirmDialog.confirm({
|
||||
title: 'Supprimer le chapitre',
|
||||
message: `Supprimer le chapitre "${chapter.name}" ?`,
|
||||
details,
|
||||
confirmLabel: 'Supprimer',
|
||||
variant: 'danger'
|
||||
}).then(ok => {
|
||||
if (!ok) return;
|
||||
this.campaignService.deleteChapter(chapter.id!).subscribe({
|
||||
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId]),
|
||||
error: () => console.error('Erreur lors de la suppression du chapitre')
|
||||
});
|
||||
});
|
||||
},
|
||||
error: () => console.error('Impossible de récupérer les dépendances du chapitre')
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.layoutService.hide();
|
||||
// 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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,8 +26,9 @@
|
||||
<div class="ce-form">
|
||||
|
||||
<div class="field">
|
||||
<label>Nom du personnage *</label>
|
||||
<label for="character-name">Nom du personnage *</label>
|
||||
<input
|
||||
id="character-name"
|
||||
type="text"
|
||||
[(ngModel)]="name"
|
||||
name="name"
|
||||
|
||||
@@ -6,10 +6,12 @@ import { LucideAngularModule, Save, ArrowLeft, User, Trash2, Sparkles } from 'lu
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { GameSystemService } from '../../../services/game-system.service';
|
||||
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
|
||||
import { TemplateField } from '../../../services/template.model';
|
||||
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||
import { DynamicFieldsFormComponent } from '../../../shared/dynamic-fields-form/dynamic-fields-form.component';
|
||||
import { SingleImagePickerComponent } from '../../../shared/single-image-picker/single-image-picker.component';
|
||||
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||
|
||||
/**
|
||||
* Editeur plein ecran d'une fiche de personnage (PJ).
|
||||
@@ -62,7 +64,9 @@ export class CharacterEditComponent implements OnInit {
|
||||
private router: Router,
|
||||
private service: CharacterService,
|
||||
private campaignService: CampaignService,
|
||||
private gameSystemService: GameSystemService
|
||||
private gameSystemService: GameSystemService,
|
||||
private campaignSidebar: CampaignSidebarService,
|
||||
private confirmDialog: ConfirmDialogService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -72,6 +76,7 @@ export class CharacterEditComponent implements OnInit {
|
||||
|
||||
if (this.campaignId) {
|
||||
this.loadTemplateForCampaign(this.campaignId);
|
||||
this.campaignSidebar.show(this.campaignId);
|
||||
}
|
||||
|
||||
if (this.characterId) {
|
||||
@@ -106,6 +111,7 @@ export class CharacterEditComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
submit(): void {
|
||||
if (!this.name.trim() || !this.campaignId) return;
|
||||
const payload = {
|
||||
@@ -117,22 +123,37 @@ export class CharacterEditComponent implements OnInit {
|
||||
keyValueValues: this.keyValueValues,
|
||||
campaignId: this.campaignId
|
||||
};
|
||||
const isCreation = !this.characterId;
|
||||
const req = this.characterId
|
||||
? this.service.update(this.characterId, { ...payload, id: this.characterId, order: this.order })
|
||||
: this.service.create(payload);
|
||||
req.subscribe({
|
||||
next: () => this.back(),
|
||||
next: (saved) => {
|
||||
if (isCreation && saved.id) {
|
||||
this.router.navigate(['/campaigns', this.campaignId, 'characters', saved.id]);
|
||||
} else {
|
||||
this.back();
|
||||
}
|
||||
},
|
||||
error: () => console.error('Erreur sauvegarde Character')
|
||||
});
|
||||
}
|
||||
|
||||
deleteCharacter(): void {
|
||||
if (!this.characterId) return;
|
||||
if (!confirm(`Supprimer la fiche de "${this.name}" ? Cette action est irreversible.`)) return;
|
||||
this.confirmDialog.confirm({
|
||||
title: 'Supprimer la fiche ?',
|
||||
message: `Supprimer la fiche de "${this.name}" ?`,
|
||||
details: ['Cette action est irreversible.'],
|
||||
confirmLabel: 'Supprimer',
|
||||
variant: 'danger'
|
||||
}).then(ok => {
|
||||
if (!ok || !this.characterId) return;
|
||||
this.service.delete(this.characterId).subscribe({
|
||||
next: () => this.back(),
|
||||
error: () => console.error('Erreur suppression Character')
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
back(): void {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { LucideAngularModule, ArrowLeft, Edit3, Sparkles } from 'lucide-angular'
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { GameSystemService } from '../../../services/game-system.service';
|
||||
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
|
||||
import { TemplateField } from '../../../services/template.model';
|
||||
import { Character } from '../../../services/character.model';
|
||||
import { PersonaViewComponent } from '../../../shared/persona-view/persona-view.component';
|
||||
@@ -40,7 +41,8 @@ export class CharacterViewComponent implements OnInit {
|
||||
private router: Router,
|
||||
private service: CharacterService,
|
||||
private campaignService: CampaignService,
|
||||
private gameSystemService: GameSystemService
|
||||
private gameSystemService: GameSystemService,
|
||||
private campaignSidebar: CampaignSidebarService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -54,6 +56,7 @@ export class CharacterViewComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
if (this.campaignId) {
|
||||
this.campaignSidebar.show(this.campaignId);
|
||||
this.campaignService.getCampaignById(this.campaignId).subscribe(camp => {
|
||||
if (camp.gameSystemId) {
|
||||
this.gameSystemService.getById(camp.gameSystemId).subscribe(gs => {
|
||||
|
||||
@@ -26,8 +26,9 @@
|
||||
<div class="ne-form">
|
||||
|
||||
<div class="field">
|
||||
<label>Nom du PNJ *</label>
|
||||
<label for="npc-name">Nom du PNJ *</label>
|
||||
<input
|
||||
id="npc-name"
|
||||
type="text"
|
||||
[(ngModel)]="name"
|
||||
name="name"
|
||||
|
||||
@@ -6,10 +6,12 @@ import { LucideAngularModule, Save, ArrowLeft, Drama, Trash2, Sparkles } from 'l
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { GameSystemService } from '../../../services/game-system.service';
|
||||
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
|
||||
import { TemplateField } from '../../../services/template.model';
|
||||
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||
import { DynamicFieldsFormComponent } from '../../../shared/dynamic-fields-form/dynamic-fields-form.component';
|
||||
import { SingleImagePickerComponent } from '../../../shared/single-image-picker/single-image-picker.component';
|
||||
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||
|
||||
/**
|
||||
* Editeur plein ecran d'une fiche de PNJ.
|
||||
@@ -57,7 +59,9 @@ export class NpcEditComponent implements OnInit {
|
||||
private router: Router,
|
||||
private service: NpcService,
|
||||
private campaignService: CampaignService,
|
||||
private gameSystemService: GameSystemService
|
||||
private gameSystemService: GameSystemService,
|
||||
private campaignSidebar: CampaignSidebarService,
|
||||
private confirmDialog: ConfirmDialogService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -67,6 +71,7 @@ export class NpcEditComponent implements OnInit {
|
||||
|
||||
if (this.campaignId) {
|
||||
this.loadTemplateForCampaign(this.campaignId);
|
||||
this.campaignSidebar.show(this.campaignId);
|
||||
}
|
||||
|
||||
if (this.npcId) {
|
||||
@@ -101,6 +106,7 @@ export class NpcEditComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
submit(): void {
|
||||
if (!this.name.trim() || !this.campaignId) return;
|
||||
const payload = {
|
||||
@@ -112,22 +118,37 @@ export class NpcEditComponent implements OnInit {
|
||||
keyValueValues: this.keyValueValues,
|
||||
campaignId: this.campaignId
|
||||
};
|
||||
const isCreation = !this.npcId;
|
||||
const req = this.npcId
|
||||
? this.service.update(this.npcId, { ...payload, id: this.npcId, order: this.order })
|
||||
: this.service.create(payload);
|
||||
req.subscribe({
|
||||
next: () => this.back(),
|
||||
next: (saved) => {
|
||||
if (isCreation && saved.id) {
|
||||
this.router.navigate(['/campaigns', this.campaignId, 'npcs', saved.id]);
|
||||
} else {
|
||||
this.back();
|
||||
}
|
||||
},
|
||||
error: () => console.error('Erreur sauvegarde Npc')
|
||||
});
|
||||
}
|
||||
|
||||
deleteNpc(): void {
|
||||
if (!this.npcId) return;
|
||||
if (!confirm(`Supprimer la fiche de "${this.name}" ? Cette action est irreversible.`)) return;
|
||||
this.confirmDialog.confirm({
|
||||
title: 'Supprimer la fiche ?',
|
||||
message: `Supprimer la fiche de "${this.name}" ?`,
|
||||
details: ['Cette action est irreversible.'],
|
||||
confirmLabel: 'Supprimer',
|
||||
variant: 'danger'
|
||||
}).then(ok => {
|
||||
if (!ok || !this.npcId) return;
|
||||
this.service.delete(this.npcId).subscribe({
|
||||
next: () => this.back(),
|
||||
error: () => console.error('Erreur suppression Npc')
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
back(): void {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { LucideAngularModule, ArrowLeft, Edit3, Sparkles } from 'lucide-angular'
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { GameSystemService } from '../../../services/game-system.service';
|
||||
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
|
||||
import { TemplateField } from '../../../services/template.model';
|
||||
import { Npc } from '../../../services/npc.model';
|
||||
import { PersonaViewComponent } from '../../../shared/persona-view/persona-view.component';
|
||||
@@ -40,7 +41,8 @@ export class NpcViewComponent implements OnInit {
|
||||
private router: Router,
|
||||
private service: NpcService,
|
||||
private campaignService: CampaignService,
|
||||
private gameSystemService: GameSystemService
|
||||
private gameSystemService: GameSystemService,
|
||||
private campaignSidebar: CampaignSidebarService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -54,6 +56,7 @@ export class NpcViewComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
if (this.campaignId) {
|
||||
this.campaignSidebar.show(this.campaignId);
|
||||
this.campaignService.getCampaignById(this.campaignId).subscribe(camp => {
|
||||
if (camp.gameSystemId) {
|
||||
this.gameSystemService.getById(camp.gameSystemId).subscribe(gs => {
|
||||
|
||||
@@ -7,9 +7,8 @@ import { LucideAngularModule } from 'lucide-angular';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
||||
import { Campaign } from '../../../services/campaign.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
|
||||
import { LayoutService } from '../../../services/layout.service';
|
||||
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
|
||||
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
||||
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
||||
|
||||
@@ -67,21 +66,7 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
|
||||
this.chapterName = currentChapter?.name ?? '';
|
||||
this.existingSceneCount = treeData.scenesByChapter[this.chapterId]?.length ?? 0;
|
||||
|
||||
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
|
||||
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
|
||||
}));
|
||||
|
||||
this.layoutService.show({
|
||||
title: campaign.name,
|
||||
items: buildCampaignTree(this.campaignId, treeData),
|
||||
footerLabel: 'Toutes les campagnes',
|
||||
createActions: [
|
||||
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${this.campaignId}/arcs/create` }
|
||||
],
|
||||
globalItems,
|
||||
globalBackLabel: 'Toutes les campagnes',
|
||||
globalBackRoute: '/campaigns'
|
||||
});
|
||||
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -94,7 +79,7 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
|
||||
order: this.existingSceneCount + 1,
|
||||
icon: this.selectedIcon
|
||||
}).subscribe({
|
||||
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', created.id, 'edit']),
|
||||
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', created.id]),
|
||||
error: () => console.error('Erreur lors de la création de la scène')
|
||||
});
|
||||
}
|
||||
@@ -104,6 +89,9 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.layoutService.hide();
|
||||
// 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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,17 +9,18 @@ import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { PageService } from '../../../services/page.service';
|
||||
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
||||
import { LayoutService } from '../../../services/layout.service';
|
||||
import { PageTitleService } from '../../../services/page-title.service';
|
||||
import { Campaign, Scene, SceneBranch } from '../../../services/campaign.model';
|
||||
import { Scene, SceneBranch } from '../../../services/campaign.model';
|
||||
import { Page } from '../../../services/page.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
|
||||
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
|
||||
import { ExpandableSectionComponent } from '../../../shared/expandable-section/expandable-section.component';
|
||||
import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-link-picker.component';
|
||||
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
||||
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
|
||||
import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
||||
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||
|
||||
/**
|
||||
* Écran de détail/modification d'une Scène.
|
||||
@@ -75,7 +76,8 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
private npcService: NpcService,
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService
|
||||
private pageTitleService: PageTitleService,
|
||||
private confirmDialog: ConfirmDialogService
|
||||
) {
|
||||
this.form = this.fb.group({
|
||||
name: ['', Validators.required],
|
||||
@@ -155,21 +157,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
enemies: scene.enemies ?? ''
|
||||
});
|
||||
|
||||
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
|
||||
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
|
||||
}));
|
||||
|
||||
this.layoutService.show({
|
||||
title: campaign.name,
|
||||
items: buildCampaignTree(this.campaignId, treeData),
|
||||
footerLabel: 'Toutes les campagnes',
|
||||
createActions: [
|
||||
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${this.campaignId}/arcs/create` }
|
||||
],
|
||||
globalItems,
|
||||
globalBackLabel: 'Toutes les campagnes',
|
||||
globalBackRoute: '/campaigns'
|
||||
});
|
||||
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -200,11 +188,19 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
delete(): void {
|
||||
if (!confirm(`Supprimer la scène "${this.scene?.name}" ? Cette action est irréversible.`)) return;
|
||||
this.confirmDialog.confirm({
|
||||
title: 'Supprimer la scène',
|
||||
message: `Supprimer la scène "${this.scene?.name}" ?`,
|
||||
details: ['Cette action est irréversible.'],
|
||||
confirmLabel: 'Supprimer',
|
||||
variant: 'danger'
|
||||
}).then(ok => {
|
||||
if (!ok) return;
|
||||
this.campaignService.deleteScene(this.sceneId).subscribe({
|
||||
next: () => this.router.navigate(['/campaigns', this.campaignId]),
|
||||
error: () => console.error('Erreur lors de la suppression')
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
@@ -236,6 +232,9 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.layoutService.hide();
|
||||
// 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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,13 @@ import { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { PageService } from '../../../services/page.service';
|
||||
import { LayoutService, GlobalItem } from '../../../services/layout.service';
|
||||
import { LayoutService } from '../../../services/layout.service';
|
||||
import { PageTitleService } from '../../../services/page-title.service';
|
||||
import { Campaign, Scene } from '../../../services/campaign.model';
|
||||
import { Scene } from '../../../services/campaign.model';
|
||||
import { Page } from '../../../services/page.model';
|
||||
import { loadCampaignTreeData, buildCampaignTree } from '../../campaign-tree.helper';
|
||||
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
|
||||
import { ImageGalleryComponent } from '../../../shared/image-gallery/image-gallery.component';
|
||||
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
|
||||
|
||||
/**
|
||||
* Écran de consultation d'une Scène (lecture seule).
|
||||
@@ -49,7 +50,8 @@ export class SceneViewComponent implements OnInit, OnDestroy {
|
||||
private npcService: NpcService,
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService
|
||||
private pageTitleService: PageTitleService,
|
||||
private confirmDialog: ConfirmDialogService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -89,20 +91,7 @@ export class SceneViewComponent implements OnInit, OnDestroy {
|
||||
this.availablePages = pages;
|
||||
this.pageTitleService.set(scene.name);
|
||||
|
||||
const globalItems: GlobalItem[] = allCampaigns.map((c: Campaign) => ({
|
||||
id: c.id!, name: c.name, route: `/campaigns/${c.id}`
|
||||
}));
|
||||
this.layoutService.show({
|
||||
title: campaign.name,
|
||||
items: buildCampaignTree(this.campaignId, treeData),
|
||||
footerLabel: 'Toutes les campagnes',
|
||||
createActions: [
|
||||
{ id: 'create-arc', label: '+ Nouvel arc', variant: 'primary', route: `/campaigns/${this.campaignId}/arcs/create` }
|
||||
],
|
||||
globalItems,
|
||||
globalBackLabel: 'Toutes les campagnes',
|
||||
globalBackRoute: '/campaigns'
|
||||
});
|
||||
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -121,16 +110,27 @@ export class SceneViewComponent implements OnInit, OnDestroy {
|
||||
deleteScene(): void {
|
||||
if (!this.scene) return;
|
||||
const scene = this.scene;
|
||||
if (!confirm(`Supprimer la scène "${scene.name}" ?\n\nCette action est irréversible.`)) return;
|
||||
this.confirmDialog.confirm({
|
||||
title: 'Supprimer la scène',
|
||||
message: `Supprimer la scène "${scene.name}" ?`,
|
||||
details: ['Cette action est irréversible.'],
|
||||
confirmLabel: 'Supprimer',
|
||||
variant: 'danger'
|
||||
}).then(ok => {
|
||||
if (!ok) return;
|
||||
this.campaignService.deleteScene(scene.id!).subscribe({
|
||||
next: () => this.router.navigate([
|
||||
'/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId
|
||||
]),
|
||||
error: () => console.error('Erreur lors de la suppression de la scène')
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.layoutService.hide();
|
||||
// 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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,18 +14,18 @@
|
||||
<div class="gse-form">
|
||||
|
||||
<div class="field">
|
||||
<label>Nom *</label>
|
||||
<input type="text" [(ngModel)]="name" name="name" placeholder="Ex: Nimble, D&D 5.1 SRD, Mon Homebrew..." />
|
||||
<label for="gs-name">Nom *</label>
|
||||
<input id="gs-name" type="text" [(ngModel)]="name" name="name" placeholder="Ex: Nimble, D&D 5.1 SRD, Mon Homebrew..." />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Description courte</label>
|
||||
<textarea [(ngModel)]="description" name="description" rows="2" placeholder="En une ligne, de quoi parle ce système ?"></textarea>
|
||||
<label for="gs-description">Description courte</label>
|
||||
<textarea id="gs-description" [(ngModel)]="description" name="description" rows="2" placeholder="En une ligne, de quoi parle ce système ?"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Auteur</label>
|
||||
<input type="text" [(ngModel)]="author" name="author" placeholder="Ex: Hasbro, Homebrew, moi-même..." />
|
||||
<label for="gs-author">Auteur</label>
|
||||
<input id="gs-author" type="text" [(ngModel)]="author" name="author" placeholder="Ex: Hasbro, Homebrew, moi-même..." />
|
||||
</div>
|
||||
|
||||
<!-- Sections de règles -->
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Router } from '@angular/router';
|
||||
import { LucideAngularModule, Dices, Plus, Pencil, Trash2 } from 'lucide-angular';
|
||||
import { GameSystemService } from '../services/game-system.service';
|
||||
import { GameSystem } from '../services/game-system.model';
|
||||
import { ConfirmDialogService } from '../shared/confirm-dialog/confirm-dialog.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-game-systems',
|
||||
@@ -22,7 +23,8 @@ export class GameSystemsComponent implements OnInit {
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
private gameSystemService: GameSystemService
|
||||
private gameSystemService: GameSystemService,
|
||||
private confirmDialog: ConfirmDialogService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -47,10 +49,18 @@ export class GameSystemsComponent implements OnInit {
|
||||
delete(system: GameSystem, event: MouseEvent): void {
|
||||
event.stopPropagation();
|
||||
if (!system.id) return;
|
||||
if (!confirm(`Supprimer le système "${system.name}" ? Les campagnes qui l'utilisent ne seront plus associées à aucun système.`)) return;
|
||||
this.confirmDialog.confirm({
|
||||
title: 'Supprimer le système',
|
||||
message: `Supprimer le système "${system.name}" ?`,
|
||||
details: ['Les campagnes qui l\'utilisent ne seront plus associées à aucun système.'],
|
||||
confirmLabel: 'Supprimer',
|
||||
variant: 'danger'
|
||||
}).then(ok => {
|
||||
if (!ok || !system.id) return;
|
||||
this.gameSystemService.delete(system.id).subscribe({
|
||||
next: () => this.load(),
|
||||
error: () => console.error('Erreur suppression GameSystem')
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Lore, LoreNode } from '../../services/lore.model';
|
||||
import { Page } from '../../services/page.model';
|
||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||
import { resolveIcon } from '../lore-icons';
|
||||
import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog.service';
|
||||
|
||||
/**
|
||||
* Vue "détail" d'un dossier : affiche son contenu (sous-dossiers + pages) et
|
||||
@@ -52,7 +53,8 @@ export class FolderViewComponent implements OnInit, OnDestroy {
|
||||
private templateService: TemplateService,
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService
|
||||
private pageTitleService: PageTitleService,
|
||||
private confirmDialog: ConfirmDialogService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -148,15 +150,20 @@ export class FolderViewComponent implements OnInit, OnDestroy {
|
||||
if (impact.folders > 0) parts.push(`${impact.folders} sous-dossier${impact.folders > 1 ? 's' : ''}`);
|
||||
if (impact.pages > 0) parts.push(`${impact.pages} page${impact.pages > 1 ? 's' : ''}`);
|
||||
|
||||
const lines = [`Supprimer le dossier "${node.name}" ?`];
|
||||
const details: string[] = [];
|
||||
if (parts.length) {
|
||||
lines.push('');
|
||||
lines.push(`Cette action supprimera aussi : ${parts.join(', ')}.`);
|
||||
details.push(`Cette action supprimera aussi : ${parts.join(', ')}.`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('Cette action est irréversible.');
|
||||
details.push('Cette action est irréversible.');
|
||||
|
||||
if (!confirm(lines.join('\n'))) return;
|
||||
this.confirmDialog.confirm({
|
||||
title: 'Supprimer le dossier',
|
||||
message: `Supprimer le dossier "${node.name}" ?`,
|
||||
details,
|
||||
confirmLabel: 'Supprimer',
|
||||
variant: 'danger'
|
||||
}).then(ok => {
|
||||
if (!ok) return;
|
||||
this.loreService.deleteLoreNode(this.folderId).subscribe({
|
||||
next: () => {
|
||||
// Remonte au dossier parent si présent, sinon au Lore.
|
||||
@@ -168,12 +175,16 @@ export class FolderViewComponent implements OnInit, OnDestroy {
|
||||
},
|
||||
error: () => console.error('Erreur lors de la suppression du dossier')
|
||||
});
|
||||
});
|
||||
},
|
||||
error: () => console.error('Impossible de récupérer les dépendances du dossier')
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.layoutService.hide();
|
||||
// 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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { LayoutService } from '../../services/layout.service';
|
||||
import { PageTitleService } from '../../services/page-title.service';
|
||||
import { Lore, LoreNode } from '../../services/lore.model';
|
||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||
import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-lore-detail',
|
||||
@@ -42,7 +43,8 @@ export class LoreDetailComponent implements OnInit, OnDestroy {
|
||||
private templateService: TemplateService,
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService
|
||||
private pageTitleService: PageTitleService,
|
||||
private confirmDialog: ConfirmDialogService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -125,26 +127,31 @@ export class LoreDetailComponent implements OnInit, OnDestroy {
|
||||
if (impact.pages > 0) deleted.push(`${impact.pages} page${impact.pages > 1 ? 's' : ''}`);
|
||||
if (impact.templates > 0) deleted.push(`${impact.templates} template${impact.templates > 1 ? 's' : ''}`);
|
||||
|
||||
const lines = [`Supprimer définitivement le Lore "${lore.name}" ?`];
|
||||
const details: string[] = [];
|
||||
if (deleted.length) {
|
||||
lines.push('');
|
||||
lines.push(`Cette action supprimera aussi : ${deleted.join(', ')}.`);
|
||||
details.push(`Cette action supprimera aussi : ${deleted.join(', ')}.`);
|
||||
}
|
||||
if (impact.detachedCampaigns > 0) {
|
||||
lines.push('');
|
||||
lines.push(
|
||||
details.push(
|
||||
`${impact.detachedCampaigns} campagne${impact.detachedCampaigns > 1 ? 's' : ''} ${impact.detachedCampaigns > 1 ? 'seront conservées' : 'sera conservée'} ` +
|
||||
`mais ${impact.detachedCampaigns > 1 ? 'perdront' : 'perdra'} leur lien vers cet univers.`
|
||||
);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('Cette action est irréversible.');
|
||||
details.push('Cette action est irréversible.');
|
||||
|
||||
if (!confirm(lines.join('\n'))) return;
|
||||
this.confirmDialog.confirm({
|
||||
title: 'Supprimer le Lore',
|
||||
message: `Supprimer définitivement le Lore "${lore.name}" ?`,
|
||||
details,
|
||||
confirmLabel: 'Supprimer',
|
||||
variant: 'danger'
|
||||
}).then(ok => {
|
||||
if (!ok) return;
|
||||
this.loreService.deleteLore(lore.id!).subscribe({
|
||||
next: () => this.router.navigate(['/lore']),
|
||||
error: () => console.error('Erreur lors de la suppression du Lore')
|
||||
});
|
||||
});
|
||||
},
|
||||
error: () => console.error('Impossible de récupérer les dépendances du Lore')
|
||||
});
|
||||
|
||||
@@ -111,6 +111,9 @@ export class LoreNodeCreateComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.layoutService.hide();
|
||||
// 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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,6 +147,9 @@ export class LoreNodeEditComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.layoutService.hide();
|
||||
// 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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,23 @@
|
||||
</div>
|
||||
<p class="template-description">{{ t.description || '—' }}</p>
|
||||
</button>
|
||||
|
||||
<!-- Carte "+" : sauvegarde le brouillon et part creer un nouveau template ;
|
||||
template-create renverra ici via le mecanisme returnTo. -->
|
||||
<a
|
||||
class="template-card template-card-create"
|
||||
[routerLink]="['/lore', loreId, 'templates', 'create']"
|
||||
[queryParams]="{ returnTo: 'page-create' }"
|
||||
(click)="saveDraft()"
|
||||
title="Créer un nouveau template pour ce Lore">
|
||||
<div class="template-card-head">
|
||||
<lucide-icon [img]="Plus" [size]="16"></lucide-icon>
|
||||
<span class="template-name">Créer un template</span>
|
||||
</div>
|
||||
<p class="template-description">
|
||||
Vous reviendrez ici automatiquement, votre saisie sera conservée.
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<ng-template #emptyTemplates>
|
||||
|
||||
@@ -116,6 +116,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Carte "+" pour creer un nouveau template depuis l'ecran de creation de page.
|
||||
// Bordure pointillee + couleurs attenuees pour la distinguer visuellement des
|
||||
// vraies cartes selectionnables (et indiquer que c'est une action, pas un
|
||||
// element de donnees).
|
||||
.template-card-create {
|
||||
border-style: dashed !important;
|
||||
border-color: #3a3a55 !important;
|
||||
background: transparent !important;
|
||||
text-decoration: none;
|
||||
|
||||
.template-card-head {
|
||||
color: #d1a878;
|
||||
.template-name { color: #d1a878; }
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: #d1a878 !important;
|
||||
background: rgba(209, 168, 120, 0.05) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { LucideAngularModule, FileText, Sparkles } from 'lucide-angular';
|
||||
import { LucideAngularModule, FileText, Sparkles, Plus } from 'lucide-angular';
|
||||
import { LoreService } from '../../services/lore.service';
|
||||
import { TemplateService } from '../../services/template.service';
|
||||
import { PageService } from '../../services/page.service';
|
||||
@@ -34,6 +34,7 @@ import { AiChatDrawerComponent, ChatPrimaryAction } from '../../shared/ai-chat-d
|
||||
export class PageCreateComponent implements OnInit, OnDestroy {
|
||||
readonly FileText = FileText;
|
||||
readonly Sparkles = Sparkles;
|
||||
readonly Plus = Plus;
|
||||
|
||||
form: FormGroup;
|
||||
loreId = '';
|
||||
@@ -117,6 +118,22 @@ export class PageCreateComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
|
||||
this.restoreDraft();
|
||||
|
||||
// Retour depuis template-create avec selectTemplateId=ID : selectionne
|
||||
// automatiquement le template fraichement cree (gagne sur restoreDraft).
|
||||
const selectId = this.route.snapshot.queryParamMap.get('selectTemplateId');
|
||||
if (selectId) {
|
||||
const tpl = this.templates.find(t => t.id === selectId);
|
||||
if (tpl) this.selectTemplate(tpl);
|
||||
// On nettoie le query-param pour ne pas re-selectionner si la page
|
||||
// est rechargee plus tard.
|
||||
this.router.navigate([], {
|
||||
relativeTo: this.route,
|
||||
queryParams: { selectTemplateId: null },
|
||||
queryParamsHandling: 'merge',
|
||||
replaceUrl: true
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -322,6 +339,9 @@ Les clés du JSON doivent correspondre EXACTEMENT aux noms de champs indiqués.
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.layoutService.hide();
|
||||
// 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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { BreadcrumbComponent, BreadcrumbItem } from '../../shared/breadcrumb/bre
|
||||
import { AiChatDrawerComponent, ChatPrimaryAction } from '../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
|
||||
import { Lore } from '../../services/lore.model';
|
||||
import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog.service';
|
||||
|
||||
/**
|
||||
* Écran d'édition d'une Page.
|
||||
@@ -90,7 +91,8 @@ export class PageEditComponent implements OnInit, OnDestroy {
|
||||
private templateService: TemplateService,
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService
|
||||
private pageTitleService: PageTitleService,
|
||||
private confirmDialog: ConfirmDialogService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -258,14 +260,24 @@ export class PageEditComponent implements OnInit, OnDestroy {
|
||||
|
||||
delete(): void {
|
||||
if (!this.page) return;
|
||||
if (!confirm(`Supprimer la page "${this.page.title}" ?`)) return;
|
||||
this.confirmDialog.confirm({
|
||||
title: 'Supprimer la page',
|
||||
message: `Supprimer la page "${this.page.title}" ?`,
|
||||
confirmLabel: 'Supprimer',
|
||||
variant: 'danger'
|
||||
}).then(ok => {
|
||||
if (!ok || !this.page) return;
|
||||
this.pageService.delete(this.pageId).subscribe({
|
||||
next: () => this.router.navigate(['/lore', this.loreId]),
|
||||
error: () => console.error('Erreur lors de la suppression de la page')
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.layoutService.hide();
|
||||
// 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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Page } from '../../services/page.model';
|
||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||
import { BreadcrumbComponent, BreadcrumbItem } from '../../shared/breadcrumb/breadcrumb.component';
|
||||
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
|
||||
import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog.service';
|
||||
|
||||
/**
|
||||
* Écran de consultation d'une Page (mode lecture seule).
|
||||
@@ -51,7 +52,8 @@ export class PageViewComponent implements OnInit, OnDestroy {
|
||||
private templateService: TemplateService,
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService
|
||||
private pageTitleService: PageTitleService,
|
||||
private confirmDialog: ConfirmDialogService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -129,7 +131,14 @@ export class PageViewComponent implements OnInit, OnDestroy {
|
||||
deletePage(): void {
|
||||
if (!this.page) return;
|
||||
const page = this.page;
|
||||
if (!confirm(`Supprimer la page "${page.title}" ?\n\nCette action est irréversible.`)) return;
|
||||
this.confirmDialog.confirm({
|
||||
title: 'Supprimer la page',
|
||||
message: `Supprimer la page "${page.title}" ?`,
|
||||
details: ['Cette action est irréversible.'],
|
||||
confirmLabel: 'Supprimer',
|
||||
variant: 'danger'
|
||||
}).then(ok => {
|
||||
if (!ok) return;
|
||||
this.pageService.delete(page.id!).subscribe({
|
||||
next: () => {
|
||||
if (page.nodeId) {
|
||||
@@ -140,9 +149,13 @@ export class PageViewComponent implements OnInit, OnDestroy {
|
||||
},
|
||||
error: () => console.error('Erreur lors de la suppression de la page')
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.layoutService.hide();
|
||||
// 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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,32 +176,40 @@ export class TemplateCreateComponent implements OnInit, OnDestroy {
|
||||
defaultNodeId: raw.defaultNodeId,
|
||||
fields: this.fields
|
||||
}).subscribe({
|
||||
next: () => this.navigateBack(),
|
||||
next: (created) => this.navigateBack(created.id ?? null),
|
||||
error: () => console.error('Erreur lors de la création du template')
|
||||
});
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.navigateBack();
|
||||
this.navigateBack(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirige vers l'écran d'origine en dépilant le premier élément du query-param
|
||||
* `returnTo` (pile de retours séparés par des virgules, ex : `page-create` ou
|
||||
* `template-create,page-create`). Sinon retombe sur la page détail du Lore.
|
||||
*
|
||||
* Si `createdTemplateId` est fourni (cas submit), on l'embarque dans le
|
||||
* query-param `selectTemplateId` pour que page-create puisse pre-selectionner
|
||||
* le template fraichement cree.
|
||||
*/
|
||||
private navigateBack(): void {
|
||||
private navigateBack(createdTemplateId: string | null): void {
|
||||
const { next, rest } = popReturnTo(this.route.snapshot.queryParamMap.get('returnTo'));
|
||||
if (next === 'page-create') {
|
||||
this.router.navigate(['/lore', this.loreId, 'pages', 'create'], {
|
||||
queryParams: rest ? { returnTo: rest } : {}
|
||||
});
|
||||
const queryParams: Record<string, string> = {};
|
||||
if (rest) queryParams['returnTo'] = rest;
|
||||
if (createdTemplateId) queryParams['selectTemplateId'] = createdTemplateId;
|
||||
this.router.navigate(['/lore', this.loreId, 'pages', 'create'], { queryParams });
|
||||
return;
|
||||
}
|
||||
this.router.navigate(['/lore', this.loreId]);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.layoutService.hide();
|
||||
// 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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { forkJoin } from 'rxjs';
|
||||
import { forkJoin, Subject } from 'rxjs';
|
||||
import { switchMap, takeUntil } from 'rxjs/operators';
|
||||
import { LucideAngularModule, Plus, Trash2, Type, Image as ImageIcon, ChevronUp, ChevronDown } from 'lucide-angular';
|
||||
import { LoreService } from '../../services/lore.service';
|
||||
import { TemplateService } from '../../services/template.service';
|
||||
@@ -12,6 +13,7 @@ import { PageTitleService } from '../../services/page-title.service';
|
||||
import { LoreNode } from '../../services/lore.model';
|
||||
import { FieldType, ImageLayout, Template, TemplateField } from '../../services/template.model';
|
||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||
import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog.service';
|
||||
|
||||
/**
|
||||
* Écran d'édition d'un Template existant.
|
||||
@@ -47,6 +49,8 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
|
||||
*/
|
||||
private originalFieldNames = new Set<string>();
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
/** True si le champ est présent depuis le chargement du template. */
|
||||
isExistingField(field: TemplateField): boolean {
|
||||
return this.originalFieldNames.has(field.name);
|
||||
@@ -60,7 +64,8 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
|
||||
private templateService: TemplateService,
|
||||
private pageService: PageService,
|
||||
private layoutService: LayoutService,
|
||||
private pageTitleService: PageTitleService
|
||||
private pageTitleService: PageTitleService,
|
||||
private confirmDialog: ConfirmDialogService
|
||||
) {
|
||||
this.form = this.fb.group({
|
||||
name: ['', Validators.required],
|
||||
@@ -70,13 +75,21 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loreId = this.route.snapshot.paramMap.get('loreId')!;
|
||||
this.templateId = this.route.snapshot.paramMap.get('templateId')!;
|
||||
|
||||
forkJoin({
|
||||
// switchMap pour annuler le chargement precedent si l'utilisateur change
|
||||
// de template avant la fin de la requete (Angular reutilise l'instance du
|
||||
// composant entre /templates/T1 et /templates/T2, donc ngOnInit ne refire
|
||||
// pas et il faut reagir aux changements de params nous-memes).
|
||||
this.route.paramMap.pipe(
|
||||
switchMap(params => {
|
||||
this.loreId = params.get('loreId')!;
|
||||
this.templateId = params.get('templateId')!;
|
||||
return forkJoin({
|
||||
sidebar: loadLoreSidebarData(this.loreId, this.loreService, this.templateService, this.pageService),
|
||||
template: this.templateService.getById(this.templateId)
|
||||
}).subscribe(({ sidebar, template }) => {
|
||||
});
|
||||
}),
|
||||
takeUntil(this.destroy$)
|
||||
).subscribe(({ sidebar, template }) => {
|
||||
this.nodes = sidebar.nodes;
|
||||
this.layoutService.show(buildLoreSidebarConfig(sidebar));
|
||||
this.hydrate(template);
|
||||
@@ -162,14 +175,25 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
delete(): void {
|
||||
if (!confirm(`Supprimer le template "${this.template?.name}" ?`)) return;
|
||||
this.confirmDialog.confirm({
|
||||
title: 'Supprimer le template',
|
||||
message: `Supprimer le template "${this.template?.name}" ?`,
|
||||
confirmLabel: 'Supprimer',
|
||||
variant: 'danger'
|
||||
}).then(ok => {
|
||||
if (!ok) return;
|
||||
this.templateService.delete(this.templateId).subscribe({
|
||||
next: () => this.router.navigate(['/lore', this.loreId]),
|
||||
error: () => console.error('Erreur lors de la suppression du template')
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.layoutService.hide();
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
// hide() volontairement retire : la sidebar reste prise en charge par le
|
||||
// composant suivant (sous-route ou detail parent) afin d'eviter qu'elle
|
||||
// disparaisse lors des navigations internes a la section.
|
||||
}
|
||||
}
|
||||
|
||||
51
web/src/app/services/campaign-sidebar.service.ts
Normal file
51
web/src/app/services/campaign-sidebar.service.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { forkJoin, Subscription } from 'rxjs';
|
||||
import { CampaignService } from './campaign.service';
|
||||
import { CharacterService } from './character.service';
|
||||
import { NpcService } from './npc.service';
|
||||
import { LayoutService } from './layout.service';
|
||||
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../campaigns/campaign-tree.helper';
|
||||
|
||||
/**
|
||||
* Service utilitaire qui charge et affiche la sidebar secondaire d'une campagne
|
||||
* (arbre arcs/chapitres/scenes + PJ/PNJ + items globaux).
|
||||
*
|
||||
* Centralise un pattern dupliquait dans 13+ composants (arc-view/edit/create,
|
||||
* chapter-*, scene-*, character-view/edit, npc-view/edit, campaign-detail) :
|
||||
* meme forkJoin de 3 sources + meme config layoutService.show().
|
||||
*
|
||||
* Utilisation :
|
||||
* ```ts
|
||||
* constructor(private campaignSidebar: CampaignSidebarService) {}
|
||||
* ngOnInit() { this.campaignSidebar.show(this.campaignId); }
|
||||
* ```
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class CampaignSidebarService {
|
||||
constructor(
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private layoutService: LayoutService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Charge les donnees et configure la sidebar secondaire pour la campagne.
|
||||
* Renvoie la Subscription pour permettre au caller de l'annuler s'il le
|
||||
* souhaite (rarement utile vu que les requetes terminent vite).
|
||||
*/
|
||||
show(campaignId: string): Subscription {
|
||||
return forkJoin({
|
||||
campaign: this.campaignService.getCampaignById(campaignId),
|
||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||
treeData: loadCampaignTreeData(
|
||||
this.campaignService,
|
||||
campaignId,
|
||||
this.characterService,
|
||||
this.npcService
|
||||
)
|
||||
}).subscribe(({ campaign, allCampaigns, treeData }) => {
|
||||
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, campaignId));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { Subscription } from 'rxjs';
|
||||
import { UpdatesService, UpdateStatus } from '../services/updates.service';
|
||||
import { ConfigService } from '../services/config.service';
|
||||
import { LicenseService, LicenseStatusDTO, BetaStatusDTO } from '../services/license.service';
|
||||
import { ConfirmDialogService } from '../shared/confirm-dialog/confirm-dialog.service';
|
||||
|
||||
/**
|
||||
* Ecran de parametrage du LLM utilise par le Brain.
|
||||
@@ -120,7 +121,8 @@ export class SettingsComponent implements OnInit {
|
||||
private router: Router,
|
||||
private updatesService: UpdatesService,
|
||||
public config: ConfigService,
|
||||
private licenseService: LicenseService
|
||||
private licenseService: LicenseService,
|
||||
private confirmDialog: ConfirmDialogService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -197,13 +199,21 @@ export class SettingsComponent implements OnInit {
|
||||
}
|
||||
|
||||
disconnectPatreon(): void {
|
||||
if (!confirm('Deconnecter ton compte Patreon ? Tu perdras l\'acces au canal beta.')) return;
|
||||
this.confirmDialog.confirm({
|
||||
title: 'Deconnecter Patreon',
|
||||
message: 'Deconnecter ton compte Patreon ?',
|
||||
details: ['Tu perdras l\'acces au canal beta.'],
|
||||
confirmLabel: 'Deconnecter',
|
||||
variant: 'warning'
|
||||
}).then(ok => {
|
||||
if (!ok) return;
|
||||
this.licenseService.disconnect().subscribe(() => {
|
||||
this.licenseStatus = null;
|
||||
this.betaStatus = null;
|
||||
this.successMessage = 'Compte Patreon deconnecte.';
|
||||
this.loadLicense();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
toggleBetaChannel(enabled: boolean): void {
|
||||
@@ -256,9 +266,14 @@ export class SettingsComponent implements OnInit {
|
||||
}
|
||||
|
||||
applyUpdate(): void {
|
||||
if (!confirm('Telecharger et redemarrer les conteneurs maintenant ? L\'app sera indisponible quelques secondes.')) {
|
||||
return;
|
||||
}
|
||||
this.confirmDialog.confirm({
|
||||
title: 'Mettre a jour',
|
||||
message: 'Telecharger et redemarrer les conteneurs maintenant ?',
|
||||
details: ['L\'app sera indisponible quelques secondes.'],
|
||||
confirmLabel: 'Mettre à jour',
|
||||
variant: 'warning'
|
||||
}).then(ok => {
|
||||
if (!ok) return;
|
||||
this.updateApplying = true;
|
||||
this.updateMessage = '';
|
||||
this.updatesService.apply().subscribe({
|
||||
@@ -274,6 +289,7 @@ export class SettingsComponent implements OnInit {
|
||||
this.updateMessage = 'Mise a jour declenchee. Rechargez la page dans 30s.';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
loadSettings(): void {
|
||||
@@ -491,7 +507,14 @@ export class SettingsComponent implements OnInit {
|
||||
}
|
||||
|
||||
deleteModel(name: string): void {
|
||||
if (!confirm(`Supprimer le modele '${name}' ? L'espace disque sera libere.`)) return;
|
||||
this.confirmDialog.confirm({
|
||||
title: 'Supprimer le modele',
|
||||
message: `Supprimer le modele '${name}' ?`,
|
||||
details: ['L\'espace disque sera libere.'],
|
||||
confirmLabel: 'Supprimer',
|
||||
variant: 'danger'
|
||||
}).then(ok => {
|
||||
if (!ok) return;
|
||||
this.deletingModel = name;
|
||||
this.errorMessage = '';
|
||||
this.settingsService.deleteOllamaModel(name).subscribe({
|
||||
@@ -511,6 +534,7 @@ export class SettingsComponent implements OnInit {
|
||||
this.errorMessage = this.extractError(err, `Echec de la suppression de ${name}.`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
goBack(): void {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { AiChatService, ChatMessage, ChatUsage, NarrativeEntityType } from '../.
|
||||
import { Conversation, ConversationContext } from '../../services/conversation.model';
|
||||
import { ConversationService } from '../../services/conversation.service';
|
||||
import { MarkdownPipe } from '../markdown.pipe';
|
||||
import { ConfirmDialogService } from '../confirm-dialog/confirm-dialog.service';
|
||||
|
||||
/**
|
||||
* Action primaire optionnelle rendue en gros bouton au-dessus des suggestions.
|
||||
@@ -119,6 +120,7 @@ export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
constructor(
|
||||
private readonly chatService: AiChatService,
|
||||
private readonly conversationService: ConversationService,
|
||||
private readonly confirmDialog: ConfirmDialogService,
|
||||
) {}
|
||||
|
||||
// --- Jauge de contexte --------------------------------------------------
|
||||
@@ -312,13 +314,20 @@ export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
deleteConversation(conv: Conversation, event: Event): void {
|
||||
event.stopPropagation();
|
||||
if (this.isStreaming) return;
|
||||
if (!confirm(`Supprimer la conversation "${conv.title}" ?`)) return;
|
||||
this.confirmDialog.confirm({
|
||||
title: 'Supprimer la conversation',
|
||||
message: `Supprimer la conversation "${conv.title}" ?`,
|
||||
confirmLabel: 'Supprimer',
|
||||
variant: 'danger'
|
||||
}).then(ok => {
|
||||
if (!ok) return;
|
||||
this.conversationService.delete(conv.id).subscribe({
|
||||
next: () => {
|
||||
this.conversations = this.conversations.filter((c) => c.id !== conv.id);
|
||||
if (this.currentConversationId === conv.id) this.resetConversationState();
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
startRenameTitle(): void {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ConfirmDialogComponent } from './confirm-dialog.component';
|
||||
import { ConfirmDialogService } from './confirm-dialog.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-confirm-dialog-host',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ConfirmDialogComponent],
|
||||
template: `
|
||||
<app-confirm-dialog
|
||||
*ngIf="(svc.state$ | async) as s"
|
||||
[open]="s.open"
|
||||
[title]="s.title"
|
||||
[message]="s.message"
|
||||
[details]="s.details"
|
||||
[confirmLabel]="s.confirmLabel"
|
||||
[cancelLabel]="s.cancelLabel"
|
||||
[variant]="s.variant"
|
||||
(confirmed)="svc.resolve(true)"
|
||||
(cancelled)="svc.resolve(false)">
|
||||
</app-confirm-dialog>
|
||||
`
|
||||
})
|
||||
export class ConfirmDialogHostComponent {
|
||||
constructor(public svc: ConfirmDialogService) {}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<div class="confirm-backdrop" *ngIf="open" (click)="onCancel()">
|
||||
<div class="confirm-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
[attr.aria-label]="title"
|
||||
[class.variant-warning]="variant === 'warning'"
|
||||
[class.variant-danger]="variant === 'danger'"
|
||||
[class.variant-info]="variant === 'info'"
|
||||
(click)="$event.stopPropagation()">
|
||||
|
||||
<div class="confirm-header">
|
||||
<div class="confirm-icon">
|
||||
<lucide-icon [img]="TriangleAlert" [size]="22"></lucide-icon>
|
||||
</div>
|
||||
<h2>{{ title }}</h2>
|
||||
<button type="button" class="btn-close" (click)="onCancel()" aria-label="Fermer">
|
||||
<lucide-icon [img]="X" [size]="18"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="confirm-body">
|
||||
<p class="confirm-message">{{ message }}</p>
|
||||
<ul *ngIf="details.length > 0" class="confirm-details">
|
||||
<li *ngFor="let line of details">{{ line }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="confirm-actions">
|
||||
<button type="button" class="btn-secondary" (click)="onCancel()">{{ cancelLabel }}</button>
|
||||
<button type="button" class="btn-confirm" (click)="onConfirm()">{{ confirmLabel }}</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
127
web/src/app/shared/confirm-dialog/confirm-dialog.component.scss
Normal file
127
web/src/app/shared/confirm-dialog/confirm-dialog.component.scss
Normal file
@@ -0,0 +1,127 @@
|
||||
.confirm-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
backdrop-filter: blur(2px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
.confirm-modal {
|
||||
background: #111827;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 16px;
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
overflow: hidden;
|
||||
|
||||
&.variant-warning { border-top: 4px solid #eab308; }
|
||||
&.variant-danger { border-top: 4px solid #ef4444; }
|
||||
&.variant-info { border-top: 4px solid #6c63ff; }
|
||||
}
|
||||
|
||||
.confirm-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid #1f2937;
|
||||
|
||||
h2 {
|
||||
flex: 1;
|
||||
color: white;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.confirm-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
|
||||
.variant-warning & { background: rgba(234, 179, 8, 0.15); color: #eab308; }
|
||||
.variant-danger & { background: rgba(239, 68, 68, 0.15); color: #ef4444; }
|
||||
.variant-info & { background: rgba(108, 99, 255, 0.15); color: #6c63ff; }
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover { color: white; }
|
||||
}
|
||||
|
||||
.confirm-body {
|
||||
padding: 1.25rem 1.5rem;
|
||||
color: #d1d5db;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.confirm-message {
|
||||
margin: 0;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.confirm-details {
|
||||
margin: 0.875rem 0 0 1.25rem;
|
||||
padding: 0;
|
||||
color: #9ca3af;
|
||||
font-size: 0.85rem;
|
||||
|
||||
li { margin-bottom: 0.25rem; }
|
||||
}
|
||||
|
||||
.confirm-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.5rem;
|
||||
background: #0d121d;
|
||||
border-top: 1px solid #1f2937;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 0.6rem 1.25rem;
|
||||
background: #1f2937;
|
||||
color: #d1d5db;
|
||||
border: 1px solid #374151;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover { background: #374151; }
|
||||
}
|
||||
|
||||
.btn-confirm {
|
||||
padding: 0.6rem 1.25rem;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
.variant-warning & { background: #eab308; color: #1f1300; &:hover { background: #d4a106; } }
|
||||
.variant-danger & { background: #ef4444; &:hover { background: #dc2626; } }
|
||||
.variant-info & { background: #6c63ff; &:hover { background: #5b52e0; } }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { LucideAngularModule, TriangleAlert, X } from 'lucide-angular';
|
||||
|
||||
export type ConfirmDialogVariant = 'warning' | 'danger' | 'info';
|
||||
|
||||
@Component({
|
||||
selector: 'app-confirm-dialog',
|
||||
standalone: true,
|
||||
imports: [CommonModule, LucideAngularModule],
|
||||
templateUrl: './confirm-dialog.component.html',
|
||||
styleUrls: ['./confirm-dialog.component.scss']
|
||||
})
|
||||
export class ConfirmDialogComponent {
|
||||
readonly TriangleAlert = TriangleAlert;
|
||||
readonly X = X;
|
||||
|
||||
@Input() open = false;
|
||||
@Input() title = 'Confirmation';
|
||||
@Input() message = '';
|
||||
@Input() details: string[] = [];
|
||||
@Input() confirmLabel = 'Confirmer';
|
||||
@Input() cancelLabel = 'Annuler';
|
||||
@Input() variant: ConfirmDialogVariant = 'warning';
|
||||
|
||||
@Output() confirmed = new EventEmitter<void>();
|
||||
@Output() cancelled = new EventEmitter<void>();
|
||||
|
||||
onConfirm(): void { this.confirmed.emit(); }
|
||||
onCancel(): void { this.cancelled.emit(); }
|
||||
}
|
||||
59
web/src/app/shared/confirm-dialog/confirm-dialog.service.ts
Normal file
59
web/src/app/shared/confirm-dialog/confirm-dialog.service.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { ConfirmDialogVariant } from './confirm-dialog.component';
|
||||
|
||||
export interface ConfirmDialogOptions {
|
||||
title?: string;
|
||||
message: string;
|
||||
details?: string[];
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
variant?: ConfirmDialogVariant;
|
||||
}
|
||||
|
||||
export interface ConfirmDialogState extends Required<Omit<ConfirmDialogOptions, 'details'>> {
|
||||
details: string[];
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
const CLOSED_STATE: ConfirmDialogState = {
|
||||
open: false,
|
||||
title: 'Confirmation',
|
||||
message: '',
|
||||
details: [],
|
||||
confirmLabel: 'Confirmer',
|
||||
cancelLabel: 'Annuler',
|
||||
variant: 'warning'
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ConfirmDialogService {
|
||||
readonly state$ = new BehaviorSubject<ConfirmDialogState>(CLOSED_STATE);
|
||||
private resolver: ((value: boolean) => void) | null = null;
|
||||
|
||||
confirm(opts: ConfirmDialogOptions): Promise<boolean> {
|
||||
// Si un dialog precedent est encore ouvert, on le resout en "false"
|
||||
// avant d'en ouvrir un nouveau pour eviter une fuite de Promise.
|
||||
if (this.resolver) {
|
||||
this.resolver(false);
|
||||
this.resolver = null;
|
||||
}
|
||||
this.state$.next({
|
||||
open: true,
|
||||
title: opts.title ?? 'Confirmation',
|
||||
message: opts.message,
|
||||
details: opts.details ?? [],
|
||||
confirmLabel: opts.confirmLabel ?? 'Confirmer',
|
||||
cancelLabel: opts.cancelLabel ?? 'Annuler',
|
||||
variant: opts.variant ?? 'warning'
|
||||
});
|
||||
return new Promise<boolean>((resolve) => { this.resolver = resolve; });
|
||||
}
|
||||
|
||||
resolve(value: boolean): void {
|
||||
const r = this.resolver;
|
||||
this.resolver = null;
|
||||
this.state$.next(CLOSED_STATE);
|
||||
if (r) r(value);
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@
|
||||
<section *ngIf="s.kind === 'TEXT'" class="pv-section">
|
||||
<h2 class="pv-section-title">{{ s.name }}</h2>
|
||||
<div class="pv-section-body">
|
||||
<p [class.with-dropcap]="s.name === firstTextSectionName" class="pv-paragraph">
|
||||
<p class="pv-paragraph">
|
||||
{{ firstParagraph(s.value) }}
|
||||
</p>
|
||||
<p *ngIf="restAfterFirstParagraph(s.value)" class="pv-paragraph">
|
||||
|
||||
@@ -290,17 +290,6 @@
|
||||
.pv-paragraph {
|
||||
margin: 0 0 14px;
|
||||
white-space: pre-wrap;
|
||||
|
||||
&.with-dropcap::first-letter {
|
||||
float: left;
|
||||
font-family: 'Cinzel', 'EB Garamond', Georgia, serif;
|
||||
font-size: 3.5rem;
|
||||
line-height: 0.9;
|
||||
font-weight: 700;
|
||||
color: #d1a878;
|
||||
padding: 4px 8px 0 0;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Etat vide --------------------------------------------------------------
|
||||
|
||||
@@ -111,15 +111,7 @@ export class PersonaViewComponent {
|
||||
return this.rendered().sections;
|
||||
}
|
||||
|
||||
/** Pour la drop cap : seul le 1er TEXT la recoit. */
|
||||
get firstTextSectionName(): string | null {
|
||||
for (const s of this.orderedSections) {
|
||||
if (s.kind === 'TEXT') return s.name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Premier paragraphe d'un texte (utilise pour la drop cap). */
|
||||
/** Premier paragraphe d'un texte (separe pour permettre un styling specifique). */
|
||||
firstParagraph(text: string): string {
|
||||
if (!text) return '';
|
||||
const paragraphs = text.split(/\n\s*\n/);
|
||||
|
||||
Reference in New Issue
Block a user