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/);