Livret PDF (export campagne) :
- refonte lisibilité façon livre de JdR : sommaire paginé, libellés de champs
en tête de ligne, encadrés « À lire aux joueurs » / secrets MJ / combat,
ligne de contexte des scènes, en-tête courant, tables zébrées
- reflète la fusion quête/conteneur : arc SYSTEM masqué, quêtes de hub
fusionnées dans leur chapitre, partie « Quêtes » sans doublon
- contenu complété : sorties de scènes, ennemis liés au bestiaire, pièces
explorables, partie « Tables aléatoires »
- polices DejaVu embarquées (couverture Unicode, licence incluse) et harness
visuel PdfExportPreviewTest (Mockito pur, tourne sans Postgres)
Graphe des liens :
- déplacé du Lore vers la campagne (les PNJ sont des entités de campagne) ;
endpoint /api/npcs/lore/{id} supprimé
- layout retravaillé (anti-chevauchement des libellés, éventail des feuilles
autour des hubs), scènes et quêtes ajoutées, libellés sur 2 lignes,
filtres de légende mémorisés, focus au survol
- positions drag & drop persistées sur la campagne (migration V23,
PUT /api/campaigns/{id}/graph-positions, bouton « Disposition auto »)
Divers :
- ImportService découpé en classes dédiées (parser d'archive, remapper d'ids,
inserters campagne/lore/état de jeu, conversion legacy des quêtes)
- fuites d'abonnements corrigées (takeUntilDestroyed sur paramMap) sur ~15 écrans
- garde-fou i18n fragments/consolidés branché sur npm run build (check-i18n.mjs),
budget SCSS anyComponentStyle relevé à 12 ko
312 lines
15 KiB
HTML
312 lines
15 KiB
HTML
@if (campaign) {
|
|
<div class="campaign-detail">
|
|
<!-- ============ Header : mode lecture ============ -->
|
|
@if (!editing) {
|
|
<div class="detail-header">
|
|
<div class="header-texts">
|
|
<h1>{{ campaign.name }}</h1>
|
|
@if (campaign.description) {
|
|
<p class="description" [class.description--clamped]="!descExpanded">{{ campaign.description }}</p>
|
|
@if (campaign.description.length > 180) {
|
|
<button type="button" class="desc-toggle" (click)="descExpanded = !descExpanded">
|
|
{{ (descExpanded ? 'campaignDetail.showLess' : 'campaignDetail.showMore') | translate }}
|
|
</button>
|
|
}
|
|
}
|
|
<div class="meta">
|
|
<span class="badge">{{ 'campaignDetail.players' | translate:{ n: campaign.playerCount || 0 } }}</span>
|
|
<!-- Badge "Univers" : lien vers le Lore associé si présent -->
|
|
@if (linkedLore) {
|
|
<a
|
|
class="badge badge-lore"
|
|
[routerLink]="['/lore', linkedLore.id]"
|
|
[title]="'campaignDetail.openLinkedLore' | translate">
|
|
<lucide-icon [img]="Globe" [size]="12"></lucide-icon>
|
|
{{ linkedLore.name }}
|
|
</a>
|
|
}
|
|
<!-- Campagne liée à un Lore qui n'existe plus (supprimé ailleurs) -->
|
|
@if (campaign.loreId && !linkedLore) {
|
|
<span class="badge badge-lore-missing" [title]="'campaignDetail.loreMissingTitle' | translate">
|
|
<lucide-icon [img]="Globe" [size]="12"></lucide-icon>
|
|
{{ 'campaignDetail.loreMissing' | translate }}
|
|
</span>
|
|
}
|
|
</div>
|
|
</div>
|
|
<div class="header-actions">
|
|
<button type="button" class="btn-secondary" (click)="openGraph()"
|
|
[title]="'campaignDetail.graphTitle' | translate">
|
|
<lucide-icon [img]="Network" [size]="14"></lucide-icon>
|
|
{{ 'campaignDetail.graph' | translate }}
|
|
</button>
|
|
<button type="button" class="btn-secondary" (click)="exportPdf()" [disabled]="exportingPdf">
|
|
<lucide-icon [img]="FileText" [size]="14"></lucide-icon>
|
|
{{ (exportingPdf ? 'campaignDetail.pdfExporting' : 'campaignDetail.pdfExport') | translate }}
|
|
</button>
|
|
<button type="button" class="btn-secondary" (click)="exportFoundry()" [disabled]="exportingFoundry">
|
|
<lucide-icon [img]="Download" [size]="14"></lucide-icon>
|
|
{{ (exportingFoundry ? 'campaignDetail.foundryExporting' : 'campaignDetail.foundryExport') | translate }}
|
|
</button>
|
|
<button type="button" class="btn-secondary" (click)="startEdit()">
|
|
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
|
|
{{ 'common.edit' | translate }}
|
|
</button>
|
|
<button type="button" class="btn-danger" (click)="deleteCampaign()">
|
|
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
|
{{ 'common.delete' | translate }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
}
|
|
<!-- ============ Guidage co-MJ : « Prochaines étapes » (Pilier B) ============ -->
|
|
@if (!editing && campaign.id) {
|
|
<app-readiness-panel [campaignId]="campaign.id" [assessment]="readiness"></app-readiness-panel>
|
|
}
|
|
<!-- ============ Header : mode édition inline ============ -->
|
|
@if (editing) {
|
|
<div class="detail-header edit-mode">
|
|
<div class="field">
|
|
<label>{{ 'common.name' | translate }}</label>
|
|
<input type="text" [(ngModel)]="editName" name="editName" required />
|
|
</div>
|
|
<div class="field">
|
|
<label>{{ 'common.description' | translate }}</label>
|
|
<textarea [(ngModel)]="editDescription" name="editDescription" rows="3"></textarea>
|
|
</div>
|
|
<div class="field">
|
|
<label>{{ 'campaignDetail.loreLabel' | translate }}</label>
|
|
<select [(ngModel)]="editLoreId" name="editLoreId">
|
|
<option value="">{{ 'campaignDetail.noLoreOption' | translate }}</option>
|
|
@for (lore of availableLores; track lore) {
|
|
<option [value]="lore.id">{{ lore.name }}</option>
|
|
}
|
|
</select>
|
|
</div>
|
|
<div class="field">
|
|
<label>{{ 'campaignDetail.gameSystemLabel' | translate }}</label>
|
|
@if (!creatingGameSystem) {
|
|
<select
|
|
[(ngModel)]="editGameSystemId"
|
|
name="editGameSystemId"
|
|
(ngModelChange)="onEditGameSystemChange($event)">
|
|
<option value="">{{ 'campaignDetail.noGameSystemOption' | translate }}</option>
|
|
@for (gs of availableGameSystems; track gs) {
|
|
<option [value]="gs.id">{{ gs.name }}</option>
|
|
}
|
|
<option [value]="CREATE_GAMESYSTEM_SENTINEL">{{ 'campaignDetail.createGameSystemOption' | translate }}</option>
|
|
</select>
|
|
}
|
|
@if (creatingGameSystem) {
|
|
<div class="inline-create">
|
|
<input
|
|
type="text"
|
|
[(ngModel)]="newGameSystemName"
|
|
name="newGameSystemName"
|
|
[placeholder]="'campaignDetail.gameSystemNamePlaceholder' | translate"
|
|
(keydown.enter)="$event.preventDefault(); submitCreateGameSystem()"
|
|
(keydown.escape)="cancelCreateGameSystem()"
|
|
autofocus
|
|
/>
|
|
<div class="inline-create-actions">
|
|
<button type="button" class="btn-inline-primary"
|
|
[disabled]="!newGameSystemName.trim() || creatingGameSystemInFlight"
|
|
(click)="submitCreateGameSystem()">
|
|
<lucide-icon [img]="Check" [size]="14"></lucide-icon>
|
|
{{ 'common.create' | translate }}
|
|
</button>
|
|
<button type="button" class="btn-inline-secondary" (click)="cancelCreateGameSystem()">
|
|
{{ 'common.cancel' | translate }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
<div class="header-actions">
|
|
<button type="button" class="btn-primary" (click)="saveEdit()" [disabled]="!editName.trim()">
|
|
{{ 'common.save' | translate }}
|
|
</button>
|
|
<button type="button" class="btn-secondary" (click)="cancelEdit()">
|
|
{{ 'common.cancel' | translate }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
}
|
|
@if (!editing) {
|
|
<section class="detail-section personas-section">
|
|
<div class="section-header">
|
|
<h2>{{ 'campaignDetail.charactersTitle' | translate }}</h2>
|
|
</div>
|
|
<!-- Les PJ ne sont plus rattachés à la campagne mais à une Partie (Playthrough) :
|
|
ils se gèrent depuis la page d'une Partie. Ici on ne liste que les PNJ
|
|
(donnée de scénario, partagée par toutes les Parties). -->
|
|
<!-- Sous-section : Personnages non-joueurs (PNJ) -->
|
|
<div class="persona-subsection">
|
|
<div class="subsection-header">
|
|
<h3>
|
|
<lucide-icon [img]="Drama" [size]="16"></lucide-icon>
|
|
{{ 'campaignDetail.npcTitle' | translate }}
|
|
@if (npcs.length > 0) {
|
|
<span class="count-badge">{{ npcs.length }}</span>
|
|
}
|
|
</h3>
|
|
<button class="btn-add" (click)="createNpc()">
|
|
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
|
{{ 'campaignDetail.newNpc' | translate }}
|
|
</button>
|
|
</div>
|
|
@if (npcs.length > 0) {
|
|
@for (g of npcGroups; track g.folder) {
|
|
@if (g.folder || npcGroups.length > 1) {
|
|
<button type="button" class="persona-folder" [class.persona-folder--none]="!g.folder" (click)="toggleNpcFolder(g.folder)">
|
|
<lucide-icon [img]="isNpcFolderCollapsed(g.folder) ? ChevronRight : ChevronDown" [size]="14"></lucide-icon>
|
|
{{ g.folder ? g.folder.split('/').join(' / ') : ('campaignTree.unclassified' | translate) }}
|
|
<span class="persona-folder-count">{{ g.items.length }}</span>
|
|
</button>
|
|
}
|
|
@if (!isNpcFolderCollapsed(g.folder)) {
|
|
<div class="characters-grid">
|
|
@for (npc of g.items; track npc.id) {
|
|
<div class="character-card" (click)="viewNpc(npc)">
|
|
<lucide-icon [img]="Drama" [size]="20" class="character-icon character-icon--npc"></lucide-icon>
|
|
<div class="character-info">
|
|
<span class="character-name">{{ npc.name }}</span>
|
|
<span class="character-snippet">{{ personaSnippet(npc) }}</span>
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
}
|
|
}
|
|
@if (npcs.length === 0) {
|
|
<div class="empty-state empty-state--compact">
|
|
<p>{{ 'campaignDetail.noNpc' | translate }}</p>
|
|
<button class="btn-add-first" (click)="createNpc()">
|
|
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
|
{{ 'campaignDetail.createFirstNpc' | translate }}
|
|
</button>
|
|
</div>
|
|
}
|
|
</div>
|
|
</section>
|
|
}
|
|
@if (!editing) {
|
|
<section class="detail-section arcs-section">
|
|
<div class="section-header">
|
|
<h2>{{ 'campaignDetail.arcsTitle' | translate }}</h2>
|
|
<div class="section-header-actions">
|
|
@if (showQuickAddScene) {
|
|
<button class="btn-add" (click)="quickAddScene()" [disabled]="quickSceneInFlight">
|
|
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
|
{{ 'campaignDetail.quickScene' | translate }}
|
|
</button>
|
|
}
|
|
<button class="btn-add" (click)="createArc()">
|
|
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
|
{{ 'campaignDetail.newArc' | translate }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
@if (arcs.length > 0 && !showQuickAddScene) {
|
|
<div class="arcs-grid" cdkDropList cdkDropListOrientation="mixed"
|
|
(cdkDropListDropped)="dropArc($event)">
|
|
@for (arc of arcs; track arc.id) {
|
|
<div class="arc-card" cdkDrag [cdkDragData]="arc" (click)="openArc(arc)">
|
|
<lucide-icon [img]="Swords" [size]="24" class="arc-icon"></lucide-icon>
|
|
<span class="arc-name">{{ arc.name }}</span>
|
|
<span class="arc-meta">{{ 'campaignDetail.chapters' | translate:{ n: chapterCountByArc[arc.id!] || 0 } }}</span>
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
<!-- Mode plat (un seul arc d'un seul chapitre) : on masque la carte de
|
|
l'arc par défaut, comme dans la sidebar. Les scènes vivent dans le volet. -->
|
|
@if (arcs.length > 0 && showQuickAddScene) {
|
|
<div class="empty-state empty-state--compact">
|
|
<p>{{ 'campaignDetail.flatModeHint' | translate }}</p>
|
|
</div>
|
|
}
|
|
@if (arcs.length === 0) {
|
|
<div class="empty-state">
|
|
<lucide-icon [img]="Swords" [size]="40" class="empty-icon"></lucide-icon>
|
|
<p>{{ 'campaignDetail.noArc' | translate }}</p>
|
|
<button class="btn-add-first" (click)="quickAddScene()" [disabled]="quickSceneInFlight">
|
|
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
|
{{ 'campaignDetail.quickSceneFirst' | translate }}
|
|
</button>
|
|
<button type="button" class="empty-alt-action" (click)="createArc()">
|
|
{{ 'campaignDetail.organizeInArcs' | translate }}
|
|
</button>
|
|
</div>
|
|
}
|
|
</section>
|
|
}
|
|
<!-- ============ Parties (Playthroughs) ============ -->
|
|
@if (!editing) {
|
|
<section class="detail-section playthroughs-section">
|
|
<div class="section-header">
|
|
<h2>
|
|
<lucide-icon [img]="Dices" [size]="18"></lucide-icon>
|
|
{{ 'campaignDetail.playthroughsTitle' | translate }}
|
|
</h2>
|
|
<button class="btn-add" [disabled]="newPlaythroughInFlight" (click)="createPlaythrough()">
|
|
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
|
{{ 'campaignDetail.newPlaythrough' | translate }}
|
|
</button>
|
|
</div>
|
|
@if (playthroughs.length > 0) {
|
|
<div class="playthroughs-grid">
|
|
@for (p of playthroughs; track p) {
|
|
<div class="playthrough-card" (click)="openPlaythrough(p)">
|
|
<lucide-icon [img]="Dices" [size]="22" class="playthrough-icon"></lucide-icon>
|
|
<div class="playthrough-info">
|
|
@if (editingPlaythroughId === p.id) {
|
|
<input class="playthrough-rename-input"
|
|
[(ngModel)]="editPlaythroughName"
|
|
(click)="$event.stopPropagation()"
|
|
(keydown.enter)="savePlaythroughRename(p)"
|
|
(keydown.escape)="cancelPlaythroughRename()" />
|
|
} @else {
|
|
<span class="playthrough-name">{{ p.name }}</span>
|
|
@if (p.description) {
|
|
<span class="playthrough-meta">{{ p.description }}</span>
|
|
}
|
|
}
|
|
</div>
|
|
@if (editingPlaythroughId === p.id) {
|
|
<button type="button" class="btn-icon" (click)="savePlaythroughRename(p); $event.stopPropagation()" [title]="'common.validate' | translate">
|
|
<lucide-icon [img]="Check" [size]="15"></lucide-icon>
|
|
</button>
|
|
<button type="button" class="btn-icon" (click)="cancelPlaythroughRename(); $event.stopPropagation()" [title]="'common.cancel' | translate">
|
|
<lucide-icon [img]="X" [size]="15"></lucide-icon>
|
|
</button>
|
|
} @else {
|
|
<button type="button" class="btn-icon playthrough-edit" (click)="startRenamePlaythrough(p, $event)" [title]="'common.rename' | translate">
|
|
<lucide-icon [img]="Pencil" [size]="15"></lucide-icon>
|
|
</button>
|
|
}
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
@if (playthroughs.length === 0) {
|
|
<div class="empty-state empty-state--compact">
|
|
<p>{{ 'campaignDetail.noPlaythrough' | translate }}</p>
|
|
</div>
|
|
}
|
|
</section>
|
|
}
|
|
<!-- Sessions retirées : elles vivent désormais dans une Partie.
|
|
Les faits narratifs sont définis dans les conditions de chaque quête
|
|
(chapter-edit > « Conditions de déblocage »). -->
|
|
</div>
|
|
}
|
|
|
|
<!-- Choix du périmètre de l'export Foundry (tout / cartes+ennemis / journaux / tables). -->
|
|
<app-foundry-export-dialog
|
|
[open]="foundryDialogOpen"
|
|
[exporting]="exportingFoundry"
|
|
(cancelled)="foundryDialogOpen = false"
|
|
(confirmed)="onFoundryExportConfirmed($event)"></app-foundry-export-dialog>
|