Some checks failed
Qualité & Sécurité / MegaLinter (PMD · Ruff · Bandit · gitleaks · hadolint) (push) Successful in 1m9s
Build & Push Images / build (brain) (push) Successful in 1m10s
Qualité & Sécurité / Trivy (CVE dépendances Maven / pip / npm) (push) Failing after 2m36s
Build & Push Images / build (web) (push) Successful in 1m48s
Build & Push Images / build-switcher (push) Successful in 20s
Build & Push Images / build (core) (push) Successful in 3m9s
Qualité & Sécurité / Web (ESLint · angular-eslint + sonarjs) (push) Successful in 38s
CI (Gitea Actions) : - Nouveau workflow quality.yml (séparé de ci.yml, non bloquant pour la release) : MegaLinter v9 (PMD, Ruff, Bandit, gitleaks, hadolint), ng lint (web) et Trivy (CVE des dépendances Maven/pip/npm), sur main + beta + PR - Trivy : préchargement du cache Maven avant le scan — sans ça Maven Central rate-limite l'IP du runner (429) en résolvant les BOMs du parent Spring Boot - upload-artifact v4 → v3 : l'API artifacts v4 n'est pas supportée par Gitea (corrige aussi l'upload du rapport Playwright de e2e.yml, cassé depuis toujours) - Ruleset PMD projet (java-pmd-ruleset.xml, auto-détecté par MegaLinter) orienté bugs réels, sans le style Lombok-hostile du défaut : 11 337 → 65 findings ; PMD en mode rapport (JAVA_PMD_DISABLE_ERRORS) le temps de purger ce backlog Web : - angular-eslint 21 + eslint-plugin-sonarjs : les règles Sonar vivent désormais dans ESLint (config web/eslint.config.js, réglages justifiés en commentaire) - tsconfig : skipLibCheck + types:[] + node_modules ré-exclu — répare les builds desktop Windows/Linux cassés par le conflit @types/eslint-scope vs ESLint 9 - Purge du backlog lint : 114 erreurs → 0 sur 66 fichiers (ngOnDestroy vides supprimés, any typés avec les vrais DTOs, outputs (close) → (closed), regex super-linéaires désamorcées, ternaires/fonctions imbriqués dépliés, intention documentée sur les catch/error volontairement vides) - Bug latent corrigé au passage : license.service.disconnect() émettait undefined au lieu de true en cas de succès (masqué par un double cast)
201 lines
8.8 KiB
HTML
201 lines
8.8 KiB
HTML
@if (page) {
|
||
<div class="page">
|
||
<app-breadcrumb [items]="breadcrumbItems"></app-breadcrumb>
|
||
<header class="page-header">
|
||
<div>
|
||
<h1>{{ page.title }}</h1>
|
||
<p class="subtitle">{{ template?.name || ('pageEdit.pageFallback' | translate) }}</p>
|
||
</div>
|
||
<div class="header-actions">
|
||
<button type="button" class="btn-ai"
|
||
(click)="toggleChat()"
|
||
[disabled]="aiLoading"
|
||
[class.active]="chatOpen"
|
||
[title]="'pageEdit.aiTitle' | translate">
|
||
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
|
||
{{ (aiLoading ? 'pageEdit.generating' : 'pageEdit.aiAssistant') | translate }}
|
||
</button>
|
||
<button type="button" class="btn-secondary" [routerLink]="['/lore', loreId, 'pages', pageId]">{{ 'common.cancel' | translate }}</button>
|
||
<button type="button" class="btn-danger" (click)="delete()">{{ 'common.delete' | translate }}</button>
|
||
<button type="button" class="btn-primary" (click)="save()" [disabled]="!title.trim()">
|
||
{{ 'common.save' | translate }}
|
||
</button>
|
||
</div>
|
||
</header>
|
||
@if (aiError) {
|
||
<div class="ai-error-banner" role="alert">
|
||
<span>{{ aiError }}</span>
|
||
<button type="button" class="ai-error-dismiss" (click)="aiError = null" [attr.aria-label]="'common.close' | translate">×</button>
|
||
</div>
|
||
}
|
||
<form class="edit-form">
|
||
<!-- Identité ----------------------------------------------------- -->
|
||
<div class="field">
|
||
<label>{{ 'common.name' | translate }}</label>
|
||
<input type="text" [(ngModel)]="title" name="title" required />
|
||
</div>
|
||
<div class="field">
|
||
<label>{{ 'pageEdit.folder' | translate }}</label>
|
||
<select [(ngModel)]="nodeId" name="nodeId">
|
||
@for (node of nodes; track node) {
|
||
<option [value]="node.id">{{ node.name }}</option>
|
||
}
|
||
</select>
|
||
<p class="hint">{{ 'pageEdit.folderHint' | translate }}</p>
|
||
</div>
|
||
<!-- Champs dynamiques du template. Rendus en grille 12 colonnes quand le
|
||
template porte une mise en page (hasLayout) ; sinon empilés (rendu
|
||
historique). Les blocs suivent l'ordre (ligne, colonne) du template. -->
|
||
@if (template?.fields?.length) {
|
||
<h2 class="section-title">{{ 'pageEdit.fields' | translate }}</h2>
|
||
<div class="edit-fields" [class.edit-fields--grid]="hasLayout">
|
||
@for (field of orderedFields; track field) {
|
||
<!-- Champ TEXT : textarea editable -->
|
||
@if (field.type === 'TEXT') {
|
||
<div class="field"
|
||
[style.grid-column]="hasLayout ? gridColumn(field) : null">
|
||
<label>{{ field.name }}</label>
|
||
<textarea
|
||
[(ngModel)]="values[keyOf(field)]"
|
||
[name]="'value_' + keyOf(field)"
|
||
rows="4"
|
||
[placeholder]="'pageEdit.valuePlaceholder' | translate:{ field: field.name }">
|
||
</textarea>
|
||
</div>
|
||
}
|
||
<!-- Champ IMAGE : galerie editable. -->
|
||
@if (field.type === 'IMAGE') {
|
||
<div class="field"
|
||
[style.grid-column]="hasLayout ? gridColumn(field) : null">
|
||
<label>{{ field.name }}</label>
|
||
<app-image-block
|
||
[editable]="true"
|
||
[imageIds]="imageValues[keyOf(field)] || []"
|
||
(imageIdsChange)="imageValues[keyOf(field)] = $event"
|
||
[framing]="imageFraming[keyOf(field)] || {}"
|
||
(framingChange)="imageFraming[keyOf(field)] = $event">
|
||
</app-image-block>
|
||
</div>
|
||
}
|
||
<!-- Champ KEY_VALUE_LIST : liste libellé/valeur (labels figés par le template). -->
|
||
@if (field.type === 'KEY_VALUE_LIST') {
|
||
<div class="field"
|
||
[style.grid-column]="hasLayout ? gridColumn(field) : null">
|
||
<label>{{ field.name }}</label>
|
||
<div class="kv-grid">
|
||
@for (lbl of field.labels ?? []; track $index) {
|
||
<div class="kv-cell">
|
||
<span class="kv-label">{{ lbl }}</span>
|
||
<input
|
||
type="text"
|
||
[(ngModel)]="keyValueValues[keyOf(field)][lbl]"
|
||
[name]="'kv_' + keyOf(field) + '_' + lbl"
|
||
placeholder="—" />
|
||
</div>
|
||
}
|
||
@if (!(field.labels ?? []).length) {
|
||
<p class="kv-empty">{{ 'pageEdit.noLabels' | translate }}</p>
|
||
}
|
||
</div>
|
||
</div>
|
||
}
|
||
<!-- Champ TABLE : colonnes figées par le template, lignes libres. -->
|
||
@if (field.type === 'TABLE') {
|
||
<div class="field"
|
||
[style.grid-column]="hasLayout ? gridColumn(field) : null">
|
||
<label>{{ field.name }}</label>
|
||
@if ((field.labels ?? []).length) {
|
||
<div class="table-edit-wrap">
|
||
<table class="table-edit">
|
||
<thead>
|
||
<tr>
|
||
@for (col of field.labels; track $index) {
|
||
<th>{{ col }}</th>
|
||
}
|
||
<th class="table-edit-actions-col"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
@for (row of tableRows(keyOf(field)); track $index; let ri = $index) {
|
||
<tr>
|
||
@for (col of field.labels; track $index) {
|
||
<td>
|
||
<input
|
||
type="text"
|
||
[(ngModel)]="row[col]"
|
||
[name]="'tbl_' + keyOf(field) + '_' + ri + '_' + col"
|
||
[placeholder]="col" />
|
||
</td>
|
||
}
|
||
<td class="table-edit-actions-col">
|
||
<button type="button" class="btn-row-delete"
|
||
(click)="removeTableRow(keyOf(field), ri)"
|
||
[attr.aria-label]="'pageEdit.deleteRowN' | translate:{ n: (ri + 1) }" [title]="'pageEdit.deleteRow' | translate">
|
||
<lucide-icon [img]="Trash2" [size]="13"></lucide-icon>
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
}
|
||
</tbody>
|
||
</table>
|
||
<button type="button" class="btn-row-add" (click)="addTableRow(keyOf(field), field.labels)">
|
||
<lucide-icon [img]="Plus" [size]="13"></lucide-icon>
|
||
{{ 'pageEdit.addRow' | translate }}
|
||
</button>
|
||
</div>
|
||
} @else {
|
||
<p class="kv-empty">{{ 'pageEdit.noColumns' | translate }}</p>
|
||
}
|
||
</div>
|
||
}
|
||
}
|
||
</div>
|
||
}
|
||
<!-- Tags --------------------------------------------------------- -->
|
||
<h2 class="section-title">{{ 'pageEdit.tags' | translate }}</h2>
|
||
<div class="field">
|
||
<app-chips-input
|
||
[value]="tags"
|
||
(valueChange)="tags = $event"
|
||
[placeholder]="'pageEdit.tagsPlaceholder' | translate">
|
||
</app-chips-input>
|
||
<p class="hint">{{ 'pageEdit.tagsHint' | translate }}</p>
|
||
</div>
|
||
<!-- Liens vers d'autres pages ----------------------------------- -->
|
||
<h2 class="section-title">{{ 'pageEdit.relatedPages' | translate }}</h2>
|
||
<div class="field">
|
||
<app-lore-link-picker
|
||
[value]="relatedPageIds"
|
||
[availablePages]="allPages"
|
||
[excludePageId]="pageId"
|
||
[loreId]="loreId"
|
||
(valueChange)="relatedPageIds = $event">
|
||
</app-lore-link-picker>
|
||
<p class="hint">{{ 'pageEdit.relatedPagesHint' | translate }}</p>
|
||
</div>
|
||
<!-- Notes privées ----------------------------------------------- -->
|
||
<h2 class="section-title">{{ 'pageEdit.privateNotes' | translate }}</h2>
|
||
<div class="field">
|
||
<textarea
|
||
[(ngModel)]="notes"
|
||
name="notes"
|
||
rows="4"
|
||
[placeholder]="'pageEdit.privateNotesPlaceholder' | translate">
|
||
</textarea>
|
||
<p class="hint">{{ 'pageEdit.privateNotesHint' | translate }}</p>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
}
|
||
|
||
<!-- Drawer chat IA (hors du .page pour pouvoir couvrir le viewport côté droit) -->
|
||
<app-ai-chat-drawer
|
||
[loreId]="loreId"
|
||
[pageId]="pageId"
|
||
[isOpen]="chatOpen"
|
||
[quickSuggestions]="chatQuickSuggestions"
|
||
[primaryAction]="chatPrimaryAction"
|
||
(closed)="chatOpen = false"
|
||
(primaryActionClick)="onChatFillRequested()">
|
||
</app-ai-chat-drawer>
|