From f24ef0891eb21b035e14aa962bb681586085d787 Mon Sep 17 00:00:00 2001 From: "IETM_FIXE\\ietm6" Date: Tue, 19 May 2026 13:37:22 +0200 Subject: [PATCH] =?UTF-8?q?Ajout=20de=20tests=20playwright=20et=20correcti?= =?UTF-8?q?on=20de=20tests=20non=20passant=20(pour=20les=20tests=20ajout?= =?UTF-8?q?=C3=A9s=20:=20partie=20game=20system=20).=20Correction=20de=20p?= =?UTF-8?q?lusieurs=20anomalies=20:=20probl=C3=A8me=20de=20switch=20entre?= =?UTF-8?q?=202=20templates=20(par=20exemple=20si=20on=20=C3=A9tait=20sur?= =?UTF-8?q?=20un=20template=201=20et=20qu'on=20voulait=20passer=20directem?= =?UTF-8?q?ent=20au=202,=20ce=20dernier=20ne=20chargeait=20pas)=20;=20corr?= =?UTF-8?q?ection=20du=20soucis=20d'apparition=20de=20la=20sidebar=20?= =?UTF-8?q?=C3=A0=20gauche=20qui=20disparaissait=20sans=20explication=20;?= =?UTF-8?q?=20probl=C3=A8me=20de=20redirection=20:=20lorsqu'on=20terminait?= =?UTF-8?q?=20de=20cr=C3=A9er=20un=20PJ=20/=20PNJ=20;=20on=20arrivait=20su?= =?UTF-8?q?r=20l'accueil=20de=20la=20campagne=20au=20lieu=20de=20voir=20le?= =?UTF-8?q?=20r=C3=A9sultat=20de=20la=20cr=C3=A9ation.=20Probl=C3=A8me=20d?= =?UTF-8?q?e=20redirection=20=C3=A9galement=20lors=20du=20clique=20sur=20u?= =?UTF-8?q?n=20PNJ=20/=20PJ=20sur=20le=20cot=C3=A9=20:=20on=20arrivait=20s?= =?UTF-8?q?ur=20l'=C3=A9dition=20au=20lieu=20de=20la=20pr=C3=A9sentation.?= =?UTF-8?q?=20Correction=20de=20la=20premi=C3=A8re=20lettre=20stylis=C3=A9?= =?UTF-8?q?e=20:=20tout=20est=20au=20m=C3=AAme=20style=20comme=20=C3=A7a?= =?UTF-8?q?=20plus=20de=20probleme=20de=20lecture.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nouveautées : stylisation des modales (notamment suppression, warning.....) avec en prime l'ajout d'un warning lors du changement de système pour avertir que les fiches persos ne sont pas conservées. Ajout d'une option pour créer un game system directement à la création d'une campagne afin de faciliter la mise en place de cette dernière. Ajout d'un bouton pour créer un nouveau template directement lorsqu'on créer une page : ça permet de créer un template et de revenir sur la page qu'on était en train de créer sans perdre le titre. Passage en bêta 0.8.4 --- .gitea/workflows/release.yml | 43 +++-- .gitignore | 1 + brain/app/main.py | 2 +- core/pom.xml | 4 +- web/e2e/fixtures/api.ts | 52 ++++++ web/e2e/tests/campaign/arc-delete.spec.ts | 16 +- web/e2e/tests/campaign/arc-edit.spec.ts | 4 + .../tests/campaign/campaign-delete.spec.ts | 16 +- web/e2e/tests/campaign/npc-create.spec.ts | 14 +- web/e2e/tests/campaign/npc-edit.spec.ts | 14 +- .../game-system/game-system-create.spec.ts | 74 +++++++++ .../game-system/game-system-delete.spec.ts | 61 +++++++ .../game-system/game-system-edit.spec.ts | 68 ++++++++ .../game-system/game-system-sections.spec.ts | 111 +++++++++++++ .../game-system/game-system-templates.spec.ts | 151 ++++++++++++++++++ web/e2e/tests/lore/lore-delete.spec.ts | 31 ++-- web/e2e/tests/lore/page-delete.spec.ts | 16 +- web/e2e/tests/lore/template-delete.spec.ts | 12 +- web/package-lock.json | 4 +- web/package.json | 2 +- web/playwright.config.ts | 8 +- web/src/app/app.component.html | 1 + web/src/app/app.component.ts | 2 + .../arc/arc-create/arc-create.component.ts | 28 +--- .../arc/arc-edit/arc-edit.component.ts | 47 +++--- .../arc/arc-view/arc-view.component.ts | 54 +++---- web/src/app/campaigns/campaign-tree.helper.ts | 40 ++++- .../campaign-create.component.html | 40 ++++- .../campaign-create.component.scss | 75 +++++++++ .../campaign-create.component.ts | 56 ++++++- .../campaign-detail.component.html | 29 +++- .../campaign-detail.component.scss | 58 +++++++ .../campaign-detail.component.ts | 132 +++++++++++---- .../chapter-create.component.ts | 26 +-- .../chapter-edit/chapter-edit.component.ts | 47 +++--- .../chapter-graph/chapter-graph.component.ts | 5 +- .../chapter-view/chapter-view.component.ts | 54 +++---- .../character-edit.component.html | 3 +- .../character-edit.component.ts | 33 +++- .../character-view.component.ts | 5 +- .../npc/npc-edit/npc-edit.component.html | 3 +- .../npc/npc-edit/npc-edit.component.ts | 33 +++- .../npc/npc-view/npc-view.component.ts | 5 +- .../scene-create/scene-create.component.ts | 28 +--- .../scene/scene-edit/scene-edit.component.ts | 47 +++--- .../scene/scene-view/scene-view.component.ts | 50 +++--- .../game-system-edit.component.html | 12 +- .../game-systems/game-systems.component.ts | 20 ++- .../lore/folder-view/folder-view.component.ts | 47 +++--- .../lore/lore-detail/lore-detail.component.ts | 31 ++-- .../lore-node-create.component.ts | 5 +- .../lore-node-edit.component.ts | 5 +- .../page-create/page-create.component.html | 17 ++ .../page-create/page-create.component.scss | 21 +++ .../lore/page-create/page-create.component.ts | 24 ++- .../app/lore/page-edit/page-edit.component.ts | 24 ++- .../app/lore/page-view/page-view.component.ts | 37 +++-- .../template-create.component.ts | 22 ++- .../template-edit/template-edit.component.ts | 52 ++++-- .../app/services/campaign-sidebar.service.ts | 51 ++++++ web/src/app/settings/settings.component.ts | 108 ++++++++----- .../ai-chat-drawer.component.ts | 21 ++- .../confirm-dialog-host.component.ts | 27 ++++ .../confirm-dialog.component.html | 34 ++++ .../confirm-dialog.component.scss | 127 +++++++++++++++ .../confirm-dialog.component.ts | 31 ++++ .../confirm-dialog/confirm-dialog.service.ts | 59 +++++++ .../persona-view/persona-view.component.html | 2 +- .../persona-view/persona-view.component.scss | 11 -- .../persona-view/persona-view.component.ts | 10 +- 70 files changed, 1908 insertions(+), 495 deletions(-) create mode 100644 web/e2e/tests/game-system/game-system-create.spec.ts create mode 100644 web/e2e/tests/game-system/game-system-delete.spec.ts create mode 100644 web/e2e/tests/game-system/game-system-edit.spec.ts create mode 100644 web/e2e/tests/game-system/game-system-sections.spec.ts create mode 100644 web/e2e/tests/game-system/game-system-templates.spec.ts create mode 100644 web/src/app/services/campaign-sidebar.service.ts create mode 100644 web/src/app/shared/confirm-dialog/confirm-dialog-host.component.ts create mode 100644 web/src/app/shared/confirm-dialog/confirm-dialog.component.html create mode 100644 web/src/app/shared/confirm-dialog/confirm-dialog.component.scss create mode 100644 web/src/app/shared/confirm-dialog/confirm-dialog.component.ts create mode 100644 web/src/app/shared/confirm-dialog/confirm-dialog.service.ts diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index b0e207d..e6b8ac8 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -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- ; 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/ pour ne pas - # casser les installs existantes qui ont REGISTRY=git.igmlcreation.fr - # dans leur .env. - # - GHCR : nouveau pattern igmlcreation/loremind- 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- (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 }} diff --git a/.gitignore b/.gitignore index d0be019..d977628 100644 --- a/.gitignore +++ b/.gitignore @@ -107,3 +107,4 @@ docker-compose.override.yml # Relais OAuth Patreon (repo Gitea separe, clone localement pour facilite) # ============================================================================ relay/ +scripts/bump-version.mjs diff --git a/brain/app/main.py b/brain/app/main.py index d390ff3..f73ed79 100644 --- a/brain/app/main.py +++ b/brain/app/main.py @@ -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", ) diff --git a/core/pom.xml b/core/pom.xml index e1f8d54..0157a89 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -8,13 +8,13 @@ org.springframework.boot spring-boot-starter-parent - 3.2.0 + 3.2.12 com.loremind loremind-core - 0.8.3 + 0.8.4-beta LoreMind Core Backend Core - Architecture Hexagonale diff --git a/web/e2e/fixtures/api.ts b/web/e2e/fixtures/api.ts index ea3c320..050282c 100644 --- a/web/e2e/fixtures/api.ts +++ b/web/e2e/fixtures/api.ts @@ -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 { + 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 { + // 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(); +} diff --git a/web/e2e/tests/campaign/arc-delete.spec.ts b/web/e2e/tests/campaign/arc-delete.spec.ts index 20544ce..93aa4f5 100644 --- a/web/e2e/tests/campaign/arc-delete.spec.ts +++ b/web/e2e/tests/campaign/arc-delete.spec.ts @@ -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$`)); diff --git a/web/e2e/tests/campaign/arc-edit.spec.ts b/web/e2e/tests/campaign/arc-edit.spec.ts index 648c34f..6453ff6 100644 --- a/web/e2e/tests/campaign/arc-edit.spec.ts +++ b/web/e2e/tests/campaign/arc-edit.spec.ts @@ -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); diff --git a/web/e2e/tests/campaign/campaign-delete.spec.ts b/web/e2e/tests/campaign/campaign-delete.spec.ts index 1b48515..34a8d23 100644 --- a/web/e2e/tests/campaign/campaign-delete.spec.ts +++ b/web/e2e/tests/campaign/campaign-delete.spec.ts @@ -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}$`)); diff --git a/web/e2e/tests/campaign/npc-create.spec.ts b/web/e2e/tests/campaign/npc-create.spec.ts index b29cf1d..f149103 100644 --- a/web/e2e/tests/campaign/npc-create.spec.ts +++ b/web/e2e/tests/campaign/npc-create.spec.ts @@ -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é, diff --git a/web/e2e/tests/campaign/npc-edit.spec.ts b/web/e2e/tests/campaign/npc-edit.spec.ts index 2b32ae9..f0d57cf 100644 --- a/web/e2e/tests/campaign/npc-edit.spec.ts +++ b/web/e2e/tests/campaign/npc-edit.spec.ts @@ -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 }) => { diff --git a/web/e2e/tests/game-system/game-system-create.spec.ts b/web/e2e/tests/game-system/game-system-create.spec.ts new file mode 100644 index 0000000..35cdf91 --- /dev/null +++ b/web/e2e/tests/game-system/game-system-create.spec.ts @@ -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(); + }); +}); diff --git a/web/e2e/tests/game-system/game-system-delete.spec.ts b/web/e2e/tests/game-system/game-system-delete.spec.ts new file mode 100644 index 0000000..0ce3871 --- /dev/null +++ b/web/e2e/tests/game-system/game-system-delete.spec.ts @@ -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(); + }); +}); diff --git a/web/e2e/tests/game-system/game-system-edit.spec.ts b/web/e2e/tests/game-system/game-system-edit.spec.ts new file mode 100644 index 0000000..a01adf4 --- /dev/null +++ b/web/e2e/tests/game-system/game-system-edit.spec.ts @@ -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(); + }); +}); diff --git a/web/e2e/tests/game-system/game-system-sections.spec.ts b/web/e2e/tests/game-system/game-system-sections.spec.ts new file mode 100644 index 0000000..9bc47b0 --- /dev/null +++ b/web/e2e/tests/game-system/game-system-sections.spec.ts @@ -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(); + }); +}); diff --git a/web/e2e/tests/game-system/game-system-templates.spec.ts b/web/e2e/tests/game-system/game-system-templates.spec.ts new file mode 100644 index 0000000..823d29c --- /dev/null +++ b/web/e2e/tests/game-system/game-system-templates.spec.ts @@ -0,0 +1,151 @@ +import { test, expect, Page } from '@playwright/test'; +import { + seedGameSystem, + deleteGameSystem, + type SeededGameSystem, +} from '../../fixtures/api'; + +/** + * Tests du composant 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(); + }); +}); diff --git a/web/e2e/tests/lore/lore-delete.spec.ts b/web/e2e/tests/lore/lore-delete.spec.ts index ea3b2f1..dc8f090 100644 --- a/web/e2e/tests/lore/lore-delete.spec.ts +++ b/web/e2e/tests/lore/lore-delete.spec.ts @@ -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); diff --git a/web/e2e/tests/lore/page-delete.spec.ts b/web/e2e/tests/lore/page-delete.spec.ts index 867e40b..bbecf86 100644 --- a/web/e2e/tests/lore/page-delete.spec.ts +++ b/web/e2e/tests/lore/page-delete.spec.ts @@ -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$`)); diff --git a/web/e2e/tests/lore/template-delete.spec.ts b/web/e2e/tests/lore/template-delete.spec.ts index fc17f9b..70bd827 100644 --- a/web/e2e/tests/lore/template-delete.spec.ts +++ b/web/e2e/tests/lore/template-delete.spec.ts @@ -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}$`)); diff --git a/web/package-lock.json b/web/package-lock.json index 94c7991..8bd95db 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index 5210136..7855672 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "loremind-web", - "version": "0.8.3", + "version": "0.8.4-beta", "description": "LoreMind Frontend - Angular", "scripts": { "ng": "ng", diff --git a/web/playwright.config.ts b/web/playwright.config.ts index 5794d43..f29381c 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -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', diff --git a/web/src/app/app.component.html b/web/src/app/app.component.html index 5bd96fa..8215e56 100644 --- a/web/src/app/app.component.html +++ b/web/src/app/app.component.html @@ -18,3 +18,4 @@ + diff --git a/web/src/app/app.component.ts b/web/src/app/app.component.ts index 5e1e924..ce89324 100644 --- a/web/src/app/app.component.ts +++ b/web/src/app/app.component.ts @@ -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, ], diff --git a/web/src/app/campaigns/arc/arc-create/arc-create.component.ts b/web/src/app/campaigns/arc/arc-create/arc-create.component.ts index 6e7263a..11a88bc 100644 --- a/web/src/app/campaigns/arc/arc-create/arc-create.component.ts +++ b/web/src/app/campaigns/arc/arc-create/arc-create.component.ts @@ -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. } } diff --git a/web/src/app/campaigns/arc/arc-edit/arc-edit.component.ts b/web/src/app/campaigns/arc/arc-edit/arc-edit.component.ts index 569423a..a661990 100644 --- a/web/src/app/campaigns/arc/arc-edit/arc-edit.component.ts +++ b/web/src/app/campaigns/arc/arc-edit/arc-edit.component.ts @@ -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,10 +171,18 @@ export class ArcEditComponent implements OnInit, OnDestroy { } delete(): void { - if (!confirm(`Supprimer l'arc "${this.arc?.name}" ? Cette action est irréversible.`)) return; - this.campaignService.deleteArc(this.arcId).subscribe({ - next: () => this.router.navigate(['/campaigns', this.campaignId]), - error: () => console.error('Erreur lors de la suppression') + 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') + }); }); } @@ -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. } } diff --git a/web/src/app/campaigns/arc/arc-view/arc-view.component.ts b/web/src/app/campaigns/arc/arc-view/arc-view.component.ts index 8947c3e..e6ab623 100644 --- a/web/src/app/campaigns/arc/arc-view/arc-view.component.ts +++ b/web/src/app/campaigns/arc/arc-view/arc-view.component.ts @@ -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,18 +111,24 @@ 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.campaignService.deleteArc(arc.id!).subscribe({ - next: () => this.router.navigate(['/campaigns', this.campaignId]), - error: () => console.error('Erreur lors de la suppression de l\'arc') + 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') @@ -141,6 +136,9 @@ export class ArcViewComponent 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. } } diff --git a/web/src/app/campaigns/campaign-tree.helper.ts b/web/src/app/campaigns/campaign-tree.helper.ts index 5d7fc90..d4059a7 100644 --- a/web/src/app/campaigns/campaign-tree.helper.ts +++ b/web/src/app/campaigns/campaign-tree.helper.ts @@ -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' + }; +} diff --git a/web/src/app/campaigns/campaign/campaign-create/campaign-create.component.html b/web/src/app/campaigns/campaign/campaign-create/campaign-create.component.html index e63e20e..02156ce 100644 --- a/web/src/app/campaigns/campaign/campaign-create/campaign-create.component.html +++ b/web/src/app/campaigns/campaign/campaign-create/campaign-create.component.html @@ -50,14 +50,50 @@
- + -

+ + +

+ +
+ + +
+

+ 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. +

+
+ +

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.

+

+ ⚠️ Le système de jeu choisi détermine aussi le template des fiches de PJ et PNJ. + 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. +

diff --git a/web/src/app/campaigns/campaign/campaign-create/campaign-create.component.scss b/web/src/app/campaigns/campaign/campaign-create/campaign-create.component.scss index 3086fe2..703b0b5 100644 --- a/web/src/app/campaigns/campaign/campaign-create/campaign-create.component.scss +++ b/web/src/app/campaigns/campaign/campaign-create/campaign-create.component.scss @@ -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; diff --git a/web/src/app/campaigns/campaign/campaign-create/campaign-create.component.ts b/web/src/app/campaigns/campaign/campaign-create/campaign-create.component.ts index c1b22b5..b9edbe5 100644 --- a/web/src/app/campaigns/campaign/campaign-create/campaign-create.component.ts +++ b/web/src/app/campaigns/campaign/campaign-create/campaign-create.component.ts @@ -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 + + +
+ +
+ + +
+
+ + + +
+ + Créer un template +
+

+ Vous reviendrez ici automatiquement, votre saisie sera conservée. +

+
diff --git a/web/src/app/lore/page-create/page-create.component.scss b/web/src/app/lore/page-create/page-create.component.scss index de57507..4c8dd99 100644 --- a/web/src/app/lore/page-create/page-create.component.scss +++ b/web/src/app/lore/page-create/page-create.component.scss @@ -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; diff --git a/web/src/app/lore/page-create/page-create.component.ts b/web/src/app/lore/page-create/page-create.component.ts index fc897ea..8da05b4 100644 --- a/web/src/app/lore/page-create/page-create.component.ts +++ b/web/src/app/lore/page-create/page-create.component.ts @@ -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. } } diff --git a/web/src/app/lore/page-edit/page-edit.component.ts b/web/src/app/lore/page-edit/page-edit.component.ts index a39184e..623a950 100644 --- a/web/src/app/lore/page-edit/page-edit.component.ts +++ b/web/src/app/lore/page-edit/page-edit.component.ts @@ -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.pageService.delete(this.pageId).subscribe({ - next: () => this.router.navigate(['/lore', this.loreId]), - error: () => console.error('Erreur lors de la suppression de la page') + 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. } } diff --git a/web/src/app/lore/page-view/page-view.component.ts b/web/src/app/lore/page-view/page-view.component.ts index 8bfc62b..3569ad1 100644 --- a/web/src/app/lore/page-view/page-view.component.ts +++ b/web/src/app/lore/page-view/page-view.component.ts @@ -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,20 +131,31 @@ 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.pageService.delete(page.id!).subscribe({ - next: () => { - if (page.nodeId) { - this.router.navigate(['/lore', this.loreId, 'folders', page.nodeId]); - } else { - this.router.navigate(['/lore', this.loreId]); - } - }, - error: () => console.error('Erreur lors de la suppression de la page') + 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) { + this.router.navigate(['/lore', this.loreId, 'folders', page.nodeId]); + } else { + 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. } } diff --git a/web/src/app/lore/template-create/template-create.component.ts b/web/src/app/lore/template-create/template-create.component.ts index 8e3c9fa..8d90ae7 100644 --- a/web/src/app/lore/template-create/template-create.component.ts +++ b/web/src/app/lore/template-create/template-create.component.ts @@ -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 = {}; + 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. } } diff --git a/web/src/app/lore/template-edit/template-edit.component.ts b/web/src/app/lore/template-edit/template-edit.component.ts index 50d597c..6ab673d 100644 --- a/web/src/app/lore/template-edit/template-edit.component.ts +++ b/web/src/app/lore/template-edit/template-edit.component.ts @@ -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(); + private destroy$ = new Subject(); + /** 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({ - sidebar: loadLoreSidebarData(this.loreId, this.loreService, this.templateService, this.pageService), - template: this.templateService.getById(this.templateId) - }).subscribe(({ sidebar, template }) => { + // 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) + }); + }), + 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.templateService.delete(this.templateId).subscribe({ - next: () => this.router.navigate(['/lore', this.loreId]), - error: () => console.error('Erreur lors de la suppression du template') + 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. } } diff --git a/web/src/app/services/campaign-sidebar.service.ts b/web/src/app/services/campaign-sidebar.service.ts new file mode 100644 index 0000000..be9f887 --- /dev/null +++ b/web/src/app/services/campaign-sidebar.service.ts @@ -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)); + }); + } +} diff --git a/web/src/app/settings/settings.component.ts b/web/src/app/settings/settings.component.ts index 8054508..d12df36 100644 --- a/web/src/app/settings/settings.component.ts +++ b/web/src/app/settings/settings.component.ts @@ -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,12 +199,20 @@ export class SettingsComponent implements OnInit { } disconnectPatreon(): void { - if (!confirm('Deconnecter ton compte Patreon ? Tu perdras l\'acces au canal beta.')) return; - this.licenseService.disconnect().subscribe(() => { - this.licenseStatus = null; - this.betaStatus = null; - this.successMessage = 'Compte Patreon deconnecte.'; - this.loadLicense(); + 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(); + }); }); } @@ -256,23 +266,29 @@ export class SettingsComponent implements OnInit { } applyUpdate(): void { - if (!confirm('Telecharger et redemarrer les conteneurs maintenant ? L\'app sera indisponible quelques secondes.')) { - return; - } - this.updateApplying = true; - this.updateMessage = ''; - this.updatesService.apply().subscribe({ - next: (r) => { - this.updateApplying = false; - // Le redemarrage de core peut couper la connexion avant la reponse — - // dans ce cas r vaut null (gere par catchError dans le service). - this.updateMessage = r?.message - ?? 'Mise a jour declenchee. Rechargez la page dans 30s.'; - }, - error: () => { - this.updateApplying = false; - this.updateMessage = 'Mise a jour declenchee. Rechargez la page dans 30s.'; - } + 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({ + next: (r) => { + this.updateApplying = false; + // Le redemarrage de core peut couper la connexion avant la reponse — + // dans ce cas r vaut null (gere par catchError dans le service). + this.updateMessage = r?.message + ?? 'Mise a jour declenchee. Rechargez la page dans 30s.'; + }, + error: () => { + this.updateApplying = false; + this.updateMessage = 'Mise a jour declenchee. Rechargez la page dans 30s.'; + } + }); }); } @@ -491,25 +507,33 @@ export class SettingsComponent implements OnInit { } deleteModel(name: string): void { - if (!confirm(`Supprimer le modele '${name}' ? L'espace disque sera libere.`)) return; - this.deletingModel = name; - this.errorMessage = ''; - this.settingsService.deleteOllamaModel(name).subscribe({ - next: () => { - this.deletingModel = null; - this.successMessage = `Modele ${name} supprime.`; - // Si l'utilisateur supprime le modele actuellement selectionne, - // on bascule sur le premier disponible (ou vide). - this.refreshModels(); - if (this.settings && this.settings.llm_model === name) { - this.settings.llm_model = ''; - this.ollamaModelMaxContext = 0; + 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({ + next: () => { + this.deletingModel = null; + this.successMessage = `Modele ${name} supprime.`; + // Si l'utilisateur supprime le modele actuellement selectionne, + // on bascule sur le premier disponible (ou vide). + this.refreshModels(); + if (this.settings && this.settings.llm_model === name) { + this.settings.llm_model = ''; + this.ollamaModelMaxContext = 0; + } + }, + error: (err) => { + this.deletingModel = null; + this.errorMessage = this.extractError(err, `Echec de la suppression de ${name}.`); } - }, - error: (err) => { - this.deletingModel = null; - this.errorMessage = this.extractError(err, `Echec de la suppression de ${name}.`); - } + }); }); } diff --git a/web/src/app/shared/ai-chat-drawer/ai-chat-drawer.component.ts b/web/src/app/shared/ai-chat-drawer/ai-chat-drawer.component.ts index 6657e32..42a8d4a 100644 --- a/web/src/app/shared/ai-chat-drawer/ai-chat-drawer.component.ts +++ b/web/src/app/shared/ai-chat-drawer/ai-chat-drawer.component.ts @@ -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,12 +314,19 @@ 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.conversationService.delete(conv.id).subscribe({ - next: () => { - this.conversations = this.conversations.filter((c) => c.id !== conv.id); - if (this.currentConversationId === conv.id) this.resetConversationState(); - }, + 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(); + }, + }); }); } diff --git a/web/src/app/shared/confirm-dialog/confirm-dialog-host.component.ts b/web/src/app/shared/confirm-dialog/confirm-dialog-host.component.ts new file mode 100644 index 0000000..fc6465b --- /dev/null +++ b/web/src/app/shared/confirm-dialog/confirm-dialog-host.component.ts @@ -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: ` + + + ` +}) +export class ConfirmDialogHostComponent { + constructor(public svc: ConfirmDialogService) {} +} diff --git a/web/src/app/shared/confirm-dialog/confirm-dialog.component.html b/web/src/app/shared/confirm-dialog/confirm-dialog.component.html new file mode 100644 index 0000000..faaf7be --- /dev/null +++ b/web/src/app/shared/confirm-dialog/confirm-dialog.component.html @@ -0,0 +1,34 @@ +
+ +
diff --git a/web/src/app/shared/confirm-dialog/confirm-dialog.component.scss b/web/src/app/shared/confirm-dialog/confirm-dialog.component.scss new file mode 100644 index 0000000..04a7239 --- /dev/null +++ b/web/src/app/shared/confirm-dialog/confirm-dialog.component.scss @@ -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; } } +} diff --git a/web/src/app/shared/confirm-dialog/confirm-dialog.component.ts b/web/src/app/shared/confirm-dialog/confirm-dialog.component.ts new file mode 100644 index 0000000..c64a9a5 --- /dev/null +++ b/web/src/app/shared/confirm-dialog/confirm-dialog.component.ts @@ -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(); + @Output() cancelled = new EventEmitter(); + + onConfirm(): void { this.confirmed.emit(); } + onCancel(): void { this.cancelled.emit(); } +} diff --git a/web/src/app/shared/confirm-dialog/confirm-dialog.service.ts b/web/src/app/shared/confirm-dialog/confirm-dialog.service.ts new file mode 100644 index 0000000..3c3ae3f --- /dev/null +++ b/web/src/app/shared/confirm-dialog/confirm-dialog.service.ts @@ -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> { + 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(CLOSED_STATE); + private resolver: ((value: boolean) => void) | null = null; + + confirm(opts: ConfirmDialogOptions): Promise { + // 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((resolve) => { this.resolver = resolve; }); + } + + resolve(value: boolean): void { + const r = this.resolver; + this.resolver = null; + this.state$.next(CLOSED_STATE); + if (r) r(value); + } +} diff --git a/web/src/app/shared/persona-view/persona-view.component.html b/web/src/app/shared/persona-view/persona-view.component.html index 5ddb9d1..8df2854 100644 --- a/web/src/app/shared/persona-view/persona-view.component.html +++ b/web/src/app/shared/persona-view/persona-view.component.html @@ -34,7 +34,7 @@

{{ s.name }}

-

+

{{ firstParagraph(s.value) }}

diff --git a/web/src/app/shared/persona-view/persona-view.component.scss b/web/src/app/shared/persona-view/persona-view.component.scss index ba41622..f6fe165 100644 --- a/web/src/app/shared/persona-view/persona-view.component.scss +++ b/web/src/app/shared/persona-view/persona-view.component.scss @@ -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 -------------------------------------------------------------- diff --git a/web/src/app/shared/persona-view/persona-view.component.ts b/web/src/app/shared/persona-view/persona-view.component.ts index 8e47d56..a5928cf 100644 --- a/web/src/app/shared/persona-view/persona-view.component.ts +++ b/web/src/app/shared/persona-view/persona-view.component.ts @@ -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/);