Initial commit — specs pré-projet SatPRO + site de présentation (viewer, annotations, gestion des accès)

This commit is contained in:
2026-10-12 12:11:04 +02:00
commit 30608afd9e
77 changed files with 20581 additions and 0 deletions
@@ -0,0 +1,97 @@
<section class="acces" aria-label="Gestion des accès">
<header class="acces__entete">
<h1 class="acces__titre">Gestion des accès</h1>
<button type="button" class="acces__btn" (click)="recharger()">⟳ Rafraîchir</button>
</header>
<p class="acces__note">
L'administrateur se configure dans le fichier <code>.env</code> et n'apparaît pas ici.
Pour un usage entre ateliers, préférez <strong>désactiver / réactiver</strong> un accès
plutôt que le supprimer.
</p>
@if (message()) {
<p class="acces__message" role="status">{{ message() }}</p>
}
@if (erreur()) {
<p class="acces__erreur" role="alert">{{ erreur() }}</p>
}
<!-- Création d'un compte -->
<form class="acces__form" (ngSubmit)="creer()">
<h2 class="acces__sous-titre">Ajouter un accès</h2>
<div class="acces__form-grille">
<label>
<span>Login</span>
<input type="text" name="login" [ngModel]="nLogin()" (ngModelChange)="nLogin.set($event)" placeholder="prenom.nom" />
</label>
<label>
<span>Mot de passe par défaut</span>
<input type="text" name="mdp" [ngModel]="nMdp()" (ngModelChange)="nMdp.set($event)" placeholder="à communiquer à l'utilisateur" />
</label>
<label>
<span>Rôle</span>
<select name="role" [ngModel]="nRole()" (ngModelChange)="nRole.set($event)">
<option value="contributeur">Contributeur (annotations)</option>
<option value="consultation">Consultation (lecture seule)</option>
<option value="desactive">Désactivé</option>
</select>
</label>
<button type="submit" class="acces__ajouter" [disabled]="creationEnCours()">
{{ creationEnCours() ? 'Création…' : 'Ajouter' }}
</button>
</div>
<p class="acces__hint">Le compte créé devra changer son mot de passe à la première connexion.</p>
</form>
<!-- Liste des comptes -->
@if (chargement()) {
<p class="acces__info">Chargement…</p>
}
@if (!chargement()) {
<table class="acces__table">
<thead>
<tr>
<th>Login</th>
<th>Rôle</th>
<th>1re connexion</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (u of comptes(); track u.login) {
<tr [class.acces__ligne--desactive]="u.role === 'desactive'">
<td class="acces__login">{{ u.login }}</td>
<td>
<select
class="acces__role-select"
[ngModel]="u.role"
(ngModelChange)="changerRole(u, $event)"
[attr.aria-label]="'Rôle de ' + u.login"
>
<option value="contributeur">Contributeur</option>
<option value="consultation">Consultation</option>
<option value="desactive">Désactivé</option>
</select>
</td>
<td>
@if (u.doitChangerMdp) {
<span class="acces__badge acces__badge--attente" title="Changement de mot de passe requis à la prochaine connexion">à changer</span>
} @else {
<span class="acces__badge acces__badge--ok">OK</span>
}
</td>
<td class="acces__actions">
<button type="button" (click)="reinitialiser(u)" title="Réinitialiser le mot de passe">Réinitialiser mdp</button>
<button type="button" class="acces__suppr" (click)="supprimer(u)" title="Supprimer le compte">Supprimer</button>
</td>
</tr>
}
@if (comptes().length === 0) {
<tr><td colspan="4" class="acces__vide">Aucun compte géré pour l'instant.</td></tr>
}
</tbody>
</table>
}
</section>
@@ -0,0 +1,194 @@
.acces {
max-width: 920px;
margin: 0 auto;
padding: 1.5rem 2rem 3rem;
}
.acces__entete {
display: flex;
align-items: center;
justify-content: space-between;
}
.acces__titre {
margin: 0;
color: var(--ds-dark-green-700);
}
.acces__btn {
background: #fff;
border: 1px solid var(--ds-gray-200);
border-radius: 7px;
padding: 0.4rem 0.75rem;
cursor: pointer;
color: var(--ds-dark-green-700);
}
.acces__btn:hover {
background: var(--ds-dark-green-50);
}
.acces__note {
font-size: 0.88rem;
color: var(--ds-gray-600);
background: var(--ds-dark-green-50);
border-left: 3px solid var(--ds-dark-green-300);
padding: 0.5rem 0.8rem;
border-radius: 0 6px 6px 0;
}
.acces__message {
color: var(--ds-dark-green-700);
background: var(--ds-dark-green-50);
padding: 0.4rem 0.7rem;
border-radius: 6px;
font-size: 0.9rem;
}
.acces__erreur {
color: var(--ds-danger-500);
font-size: 0.9rem;
}
.acces__form {
border: 1px solid var(--ds-gray-200);
border-radius: 10px;
padding: 1rem 1.1rem;
margin: 1rem 0 1.5rem;
}
.acces__sous-titre {
margin: 0 0 0.75rem;
font-size: 1.05rem;
color: var(--ds-dark-green-700);
}
.acces__form-grille {
display: grid;
grid-template-columns: 1.2fr 1.2fr 1fr auto;
gap: 0.75rem;
align-items: end;
}
.acces__form-grille label {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.82rem;
}
.acces__form-grille input,
.acces__form-grille select {
padding: 0.5rem;
border: 1px solid var(--ds-gray-200);
border-radius: 7px;
font-size: 0.9rem;
font-family: inherit;
}
.acces__ajouter {
background: var(--ds-dark-green-500);
color: #fff;
border: none;
border-radius: 7px;
padding: 0.55rem 1rem;
cursor: pointer;
font-size: 0.9rem;
height: fit-content;
}
.acces__ajouter:disabled {
opacity: 0.6;
cursor: default;
}
.acces__hint {
margin: 0.6rem 0 0;
font-size: 0.78rem;
color: var(--ds-gray-500);
}
.acces__table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.acces__table th,
.acces__table td {
border: 1px solid var(--ds-gray-200);
padding: 0.55rem 0.7rem;
text-align: left;
vertical-align: middle;
}
.acces__table th {
background: var(--ds-dark-green-50);
color: var(--ds-dark-green-700);
}
.acces__login {
font-weight: 600;
}
.acces__ligne--desactive {
opacity: 0.6;
}
.acces__role-select {
padding: 0.35rem 0.5rem;
border: 1px solid var(--ds-gray-200);
border-radius: 6px;
font-family: inherit;
}
.acces__badge {
font-size: 0.74rem;
padding: 0.1rem 0.5rem;
border-radius: 999px;
}
.acces__badge--attente {
background: var(--ds-yellow-200);
color: var(--ds-yellow-700);
}
.acces__badge--ok {
background: var(--ds-dark-green-100);
color: var(--ds-dark-green-700);
}
.acces__actions {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.acces__actions button {
border: 1px solid var(--ds-gray-200);
background: #fff;
border-radius: 6px;
padding: 0.3rem 0.55rem;
cursor: pointer;
font-size: 0.8rem;
color: var(--ds-gray-800);
}
.acces__actions button:hover {
background: var(--ds-gray-50);
}
.acces__suppr {
color: var(--ds-danger-500) !important;
}
.acces__vide {
text-align: center;
color: var(--ds-gray-500);
}
@media (max-width: 760px) {
.acces__form-grille {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,144 @@
import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import {
UserService,
Utilisateur,
RoleGerable
} from '../../core/services/user.service';
/**
* Page « Gestion des accès » (administrateur) : liste des comptes gérés, création,
* changement de rôle (consultation / contributeur / désactivé), réinitialisation de
* mot de passe et suppression.
*/
@Component({
selector: 'app-users',
standalone: true,
imports: [FormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './users.component.html',
styleUrl: './users.component.scss'
})
export class UsersComponent implements OnInit {
private readonly userService = inject(UserService);
readonly comptes = signal<Utilisateur[]>([]);
readonly chargement = signal(false);
readonly erreur = signal<string | null>(null);
readonly message = signal<string | null>(null);
// Formulaire de création
readonly nLogin = signal('');
readonly nMdp = signal('');
readonly nRole = signal<RoleGerable>('contributeur');
readonly creationEnCours = signal(false);
ngOnInit(): void {
this.recharger();
}
recharger(): void {
this.chargement.set(true);
this.erreur.set(null);
this.userService.lister().subscribe({
next: (c) => {
this.comptes.set(c);
this.chargement.set(false);
},
error: () => {
this.chargement.set(false);
this.erreur.set('Impossible de charger les comptes.');
}
});
}
private flash(msg: string): void {
this.message.set(msg);
setTimeout(() => this.message.set(null), 3500);
}
creer(): void {
this.erreur.set(null);
const login = this.nLogin().trim();
const mdp = this.nMdp();
if (!login || !mdp) {
this.erreur.set('Login et mot de passe par défaut requis.');
return;
}
this.creationEnCours.set(true);
this.userService.creer(login, mdp, this.nRole()).subscribe({
next: () => {
this.creationEnCours.set(false);
this.nLogin.set('');
this.nMdp.set('');
this.nRole.set('contributeur');
this.flash(`Compte « ${login} » créé.`);
this.recharger();
},
error: (e) => {
this.creationEnCours.set(false);
this.erreur.set(e?.error?.erreur || 'Échec de la création.');
}
});
}
changerRole(u: Utilisateur, role: RoleGerable): void {
if (role === u.role) {
return;
}
this.userService.definirRole(u.login, role).subscribe({
next: () => {
this.flash(`Rôle de « ${u.login} » : ${this.libelleRole(role)}.`);
this.recharger();
},
error: (e) => this.erreur.set(e?.error?.erreur || 'Échec du changement de rôle.')
});
}
reinitialiser(u: Utilisateur): void {
const mdp = prompt(
`Nouveau mot de passe par défaut pour « ${u.login} »\n(l'utilisateur devra le changer à sa prochaine connexion) :`
);
if (mdp === null) {
return;
}
if (mdp.length < 4) {
this.erreur.set('Le mot de passe doit faire au moins 4 caractères.');
return;
}
this.userService.reinitialiserMotDePasse(u.login, mdp).subscribe({
next: () => {
this.flash(`Mot de passe de « ${u.login} » réinitialisé.`);
this.recharger();
},
error: (e) => this.erreur.set(e?.error?.erreur || 'Échec de la réinitialisation.')
});
}
supprimer(u: Utilisateur): void {
if (!confirm(`Supprimer définitivement le compte « ${u.login} » ?\n(pour un usage entre ateliers, préférez « désactiver »)`)) {
return;
}
this.userService.supprimer(u.login).subscribe({
next: () => {
this.flash(`Compte « ${u.login} » supprimé.`);
this.recharger();
},
error: (e) => this.erreur.set(e?.error?.erreur || 'Échec de la suppression.')
});
}
libelleRole(role: RoleGerable): string {
switch (role) {
case 'consultation':
return 'Consultation (lecture seule)';
case 'contributeur':
return 'Contributeur (annotations)';
case 'desactive':
return 'Désactivé';
default:
return role;
}
}
}
@@ -0,0 +1,53 @@
<section class="login" aria-label="Changement de mot de passe">
<form class="login__carte" (ngSubmit)="soumettre()">
<h1 class="login__titre">SatPRO</h1>
<p class="login__sous-titre">
@if (impose()) {
Première connexion : définissez votre nouveau mot de passe.
} @else {
Changer votre mot de passe
}
</p>
<label class="login__champ">
<span>Mot de passe actuel</span>
<app-password-input
name="ancien"
autocomplete="current-password"
[ngModel]="ancien()"
(ngModelChange)="ancien.set($event)"
[disabled]="enCours()"
/>
</label>
<label class="login__champ">
<span>Nouveau mot de passe</span>
<app-password-input
name="nouveau"
autocomplete="new-password"
[ngModel]="nouveau()"
(ngModelChange)="nouveau.set($event)"
[disabled]="enCours()"
/>
</label>
<label class="login__champ">
<span>Confirmer le nouveau mot de passe</span>
<app-password-input
name="confirmation"
autocomplete="new-password"
[ngModel]="confirmation()"
(ngModelChange)="confirmation.set($event)"
[disabled]="enCours()"
/>
</label>
@if (erreur()) {
<p class="login__erreur" role="alert">{{ erreur() }}</p>
}
<button type="submit" class="login__bouton" [disabled]="enCours()">
{{ enCours() ? 'Enregistrement…' : 'Changer le mot de passe' }}
</button>
</form>
</section>
@@ -0,0 +1,65 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { AuthService } from '../../core/services/auth.service';
import { PasswordInputComponent } from '../../shared/password-input.component';
/**
* Écran de changement de mot de passe. Imposé à la première connexion et après
* une réinitialisation par l'administrateur (flag `doitChangerMdp`).
*/
@Component({
selector: 'app-change-password',
standalone: true,
imports: [FormsModule, PasswordInputComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './change-password.component.html',
styleUrl: './login.component.scss'
})
export class ChangePasswordComponent {
private readonly auth = inject(AuthService);
private readonly router = inject(Router);
readonly ancien = signal('');
readonly nouveau = signal('');
readonly confirmation = signal('');
readonly erreur = signal<string | null>(null);
readonly enCours = signal(false);
/** Vrai si le changement est imposé (première connexion / reset). */
readonly impose = this.auth.doitChangerMdp;
soumettre(): void {
this.erreur.set(null);
const ancien = this.ancien();
const nouveau = this.nouveau();
if (!ancien || !nouveau) {
this.erreur.set('Renseignez l’ancien et le nouveau mot de passe.');
return;
}
if (nouveau.length < 4) {
this.erreur.set('Le nouveau mot de passe doit faire au moins 4 caractères.');
return;
}
if (nouveau !== this.confirmation()) {
this.erreur.set('La confirmation ne correspond pas.');
return;
}
if (nouveau === ancien) {
this.erreur.set('Le nouveau mot de passe doit être différent de l’ancien.');
return;
}
this.enCours.set(true);
this.auth.changerMotDePasse(ancien, nouveau).subscribe({
next: () => {
this.enCours.set(false);
void this.router.navigateByUrl('/specs/00-sommaire');
},
error: (e) => {
this.enCours.set(false);
this.erreur.set(e?.error?.erreur || 'Échec du changement de mot de passe.');
}
});
}
}
@@ -0,0 +1,37 @@
<section class="login" aria-label="Connexion">
<form class="login__carte" (ngSubmit)="soumettre()">
<h1 class="login__titre">SatPRO</h1>
<p class="login__sous-titre">Specs pré-projet — accès restreint</p>
<label class="login__champ">
<span>Identifiant</span>
<input
type="text"
name="login"
autocomplete="username"
[ngModel]="login()"
(ngModelChange)="login.set($event)"
[disabled]="enCours()"
/>
</label>
<label class="login__champ">
<span>Mot de passe</span>
<app-password-input
name="motDePasse"
autocomplete="current-password"
[ngModel]="motDePasse()"
(ngModelChange)="motDePasse.set($event)"
[disabled]="enCours()"
/>
</label>
@if (erreur()) {
<p class="login__erreur" role="alert">{{ erreur() }}</p>
}
<button type="submit" class="login__bouton" [disabled]="enCours()">
{{ enCours() ? 'Connexion…' : 'Se connecter' }}
</button>
</form>
</section>
@@ -0,0 +1,80 @@
.login {
display: flex;
align-items: center;
justify-content: center;
min-height: calc(100vh - 56px);
padding: 1.5rem;
}
.login__carte {
width: 100%;
max-width: 360px;
background: #fff;
border: 1px solid var(--gris-bord);
border-radius: 12px;
padding: 2rem 1.75rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.06);
display: flex;
flex-direction: column;
gap: 1rem;
}
.login__titre {
margin: 0;
color: var(--dekra-vert-fonce);
text-align: center;
font-size: 2rem;
}
.login__sous-titre {
margin: 0 0 0.5rem;
text-align: center;
color: var(--gris-doux);
font-size: 0.9rem;
}
.login__champ {
display: flex;
flex-direction: column;
gap: 0.35rem;
font-size: 0.9rem;
}
.login__champ input {
padding: 0.6rem 0.7rem;
border: 1px solid var(--gris-bord);
border-radius: 8px;
font-size: 0.95rem;
}
.login__champ input:focus {
outline: none;
border-color: var(--dekra-vert);
box-shadow: 0 0 0 3px rgba(0, 122, 51, 0.15);
}
.login__erreur {
margin: 0;
color: var(--ds-danger-500);
font-size: 0.85rem;
}
.login__bouton {
margin-top: 0.25rem;
background: var(--dekra-vert);
color: #fff;
border: none;
border-radius: 8px;
padding: 0.7rem;
font-size: 0.95rem;
cursor: pointer;
}
.login__bouton:hover:not(:disabled) {
background: var(--dekra-vert-fonce);
}
.login__bouton:disabled {
opacity: 0.65;
cursor: default;
}
@@ -0,0 +1,58 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { AuthService } from '../../core/services/auth.service';
import { PasswordInputComponent } from '../../shared/password-input.component';
/** Page de connexion : login + mot de passe, vers les comptes configurés côté backend. */
@Component({
selector: 'app-login',
standalone: true,
imports: [FormsModule, PasswordInputComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './login.component.html',
styleUrl: './login.component.scss'
})
export class LoginComponent {
private readonly auth = inject(AuthService);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
readonly login = signal('');
readonly motDePasse = signal('');
readonly erreur = signal<string | null>(null);
readonly enCours = signal(false);
soumettre(): void {
this.erreur.set(null);
const identifiant = this.login().trim();
const mdp = this.motDePasse();
if (!identifiant || !mdp) {
this.erreur.set('Renseigne un identifiant et un mot de passe.');
return;
}
this.enCours.set(true);
this.erreur.set(null);
this.auth.seConnecter(identifiant, mdp).subscribe({
next: () => {
this.enCours.set(false);
if (this.auth.doitChangerMdp()) {
// Première connexion ou après réinitialisation : changement imposé.
void this.router.navigateByUrl('/changer-mot-de-passe');
return;
}
const retour = this.route.snapshot.queryParamMap.get('retour');
void this.router.navigateByUrl(retour || '/specs/00-sommaire');
},
error: (e) => {
this.enCours.set(false);
if (e?.status === 403) {
this.erreur.set('Ce compte est désactivé. Contactez l’administrateur.');
} else {
this.erreur.set('Identifiants invalides.');
}
}
});
}
}
@@ -0,0 +1,176 @@
<section class="specs" aria-label="Documents de spécification">
<aside class="specs__menu" aria-label="Liste des documents">
<h2 class="specs__menu-titre">Documents</h2>
<nav class="specs__liens">
@for (doc of documents(); track doc.cle) {
<a
class="specs__lien"
[routerLink]="['/specs', doc.cle]"
routerLinkActive="specs__lien--actif"
>
{{ doc.titre }}
</a>
}
</nav>
</aside>
<article class="specs__contenu">
<div class="specs__barre">
<button type="button" class="specs__btn" (click)="rafraichir()" title="Recharger la dernière version du document">
⟳ Rafraîchir
</button>
<button type="button" class="specs__btn specs__btn--annot" (click)="basculerPanneau()" title="Afficher / masquer les annotations">
💬 Annotations
@if (nbOuvertes() > 0) {
<span class="specs__badge">{{ nbOuvertes() }}</span>
}
</button>
</div>
@if (chargement()) {
<p class="specs__info">Chargement du document…</p>
}
@if (erreur()) {
<p class="specs__erreur" role="alert">{{ erreur() }}</p>
}
@if (!chargement() && !erreur() && contenuHtml()) {
<div #markdownHost class="specs__markdown markdown-body" [innerHTML]="contenuHtml()"></div>
}
</article>
<!-- Panneau latéral des annotations -->
@if (panneauOuvert()) {
<aside class="annot" aria-label="Annotations du document">
<!-- Zone haute figée : titre, export, formulaire, filtre -->
<div class="annot__haut">
<header class="annot__entete">
<h2 class="annot__titre">Annotations</h2>
<button type="button" class="annot__fermer" (click)="basculerPanneau()" title="Fermer">✕</button>
</header>
@if (!peutContribuer()) {
<p class="annot__lecture-seule">
Votre accès est en <strong>lecture seule</strong> : vous pouvez consulter les
annotations mais pas en ajouter. Contactez l'administrateur pour un accès contributeur.
</p>
}
<!-- Formulaire création / édition (contributeurs et admin uniquement) -->
@if (peutContribuer()) {
<div class="annot__form-bloc">
<button
type="button"
class="annot__form-toggle"
(click)="formReplie.set(!formReplie())"
[attr.aria-expanded]="!formReplie()"
[title]="formReplie() ? 'Déplier la saisie' : 'Replier la saisie'"
>
<span class="annot__form-chevron">{{ formReplie() ? '▸' : '▾' }}</span>
{{ editionId() ? 'Modifier l’annotation' : 'Ajouter une annotation' }}
</button>
@if (!formReplie()) {
<form class="annot__form" (ngSubmit)="soumettreForm()">
<p class="annot__form-cible">
@if (fTitreSection()) {
Section : <strong>{{ fTitreSection() }}</strong>
} @else {
Nouvelle annotation
}
@if (editionId()) { <em>(édition)</em> }
</p>
<label class="annot__champ">
<span>Type</span>
<select [ngModel]="fType()" (ngModelChange)="fType.set($event)" name="type">
<option value="remarque">Remarque</option>
<option value="ajout">Ajout</option>
<option value="correction">Correction</option>
<option value="question">Question</option>
</select>
</label>
<label class="annot__champ">
<span>Texte</span>
<textarea
rows="5"
name="texte"
[ngModel]="fTexte()"
(ngModelChange)="fTexte.set($event)"
placeholder="Votre remarque, ajout ou correction…"
></textarea>
</label>
@if (erreurForm()) {
<p class="annot__erreur" role="alert">{{ erreurForm() }}</p>
}
<div class="annot__form-actions">
<button type="submit" class="annot__valider" [disabled]="envoiEnCours()">
{{ editionId() ? 'Enregistrer' : 'Ajouter' }}
</button>
@if (editionId()) {
<button type="button" class="annot__annuler" (click)="annulerForm()">Annuler</button>
}
</div>
<p class="annot__hint">
Astuce : cliquez sur 💬 à côté d'un titre de section pour préremplir la cible.
</p>
</form>
}
</div>
}
<!-- Barre de filtre -->
<div class="annot__filtre">
<span class="annot__filtre-compte">
{{ nbOuvertes() }} ouverte(s) · {{ nbTraitees() }} traitée(s)
</span>
<label class="annot__toggle" title="Afficher ou masquer les annotations traitées">
<input
type="checkbox"
[checked]="masquerTraitees()"
(change)="masquerTraitees.set($any($event.target).checked)"
/>
Masquer les traitées
</label>
</div>
</div>
<!-- Liste des annotations du document (défile sous la zone haute) -->
<div class="annot__liste">
@if (annotations().length === 0) {
<p class="annot__vide">Aucune annotation sur ce document pour l'instant.</p>
} @else if (annotationsAffichees().length === 0) {
<p class="annot__vide">Toutes les annotations sont traitées (filtre actif).</p>
}
@for (a of annotationsAffichees(); track a.id) {
<div class="annot__item" [class.annot__item--traitee]="a.statut === 'traitee'">
<div class="annot__item-tete">
<span class="annot__type annot__type--{{ a.type }}">{{ a.type }}</span>
<span class="annot__auteur">{{ a.auteur }}</span>
@if (a.statut === 'traitee') {
<span class="annot__statut">traitée</span>
}
</div>
@if (a.titreSection) {
<div class="annot__section">↳ {{ a.titreSection }}</div>
}
<p class="annot__texte">{{ a.texte }}</p>
@if (peutModifier(a)) {
<div class="annot__actions">
<button type="button" (click)="ouvrirEdition(a)">Éditer</button>
<button type="button" (click)="basculerStatut(a)">
{{ a.statut === 'ouverte' ? 'Marquer traitée' : 'Rouvrir' }}
</button>
<button type="button" class="annot__suppr" (click)="supprimer(a)">Supprimer</button>
</div>
}
</div>
}
</div>
</aside>
}
</section>
@@ -0,0 +1,460 @@
.specs {
display: grid;
grid-template-columns: 280px 1fr;
align-items: start;
}
/* Quand le panneau d'annotations est ouvert, 3 colonnes. */
.specs:has(.annot) {
grid-template-columns: 280px 1fr 360px;
}
.specs__menu {
background: #fff;
border-right: 1px solid var(--gris-bord);
padding: 1.25rem 0.75rem;
/* Reste visible au défilement, scroll interne si la liste est longue. */
position: sticky;
top: 0;
align-self: start;
height: calc(100vh - 56px);
overflow-y: auto;
}
.specs__menu-titre {
margin: 0 0.5rem 0.75rem;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--gris-doux);
}
.specs__liens {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.specs__lien {
display: block;
padding: 0.5rem 0.65rem;
border-radius: 7px;
color: var(--gris-texte);
text-decoration: none;
font-size: 0.9rem;
border-left: 3px solid transparent;
}
.specs__lien:hover {
background: var(--gris-fond);
}
.specs__lien--actif {
background: #eef7f0;
border-left-color: var(--dekra-vert);
color: var(--dekra-vert-fonce);
font-weight: 600;
}
.specs__contenu {
padding: 1.5rem 2rem 3rem;
min-width: 0; /* évite le débordement horizontal des tableaux/pre larges */
}
.specs__barre {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-bottom: 0.75rem;
/* Reste accessible pendant la lecture (bouton Rafraîchir / Annotations). */
position: sticky;
top: 0;
z-index: 2;
padding: 0.5rem 0;
background: linear-gradient(var(--gris-fond) 70%, transparent);
}
.specs__btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
background: #fff;
border: 1px solid var(--gris-bord);
border-radius: 7px;
padding: 0.4rem 0.75rem;
font-size: 0.85rem;
cursor: pointer;
color: var(--dekra-vert-fonce);
}
.specs__btn:hover {
background: #eef7f0;
border-color: var(--dekra-vert-clair);
}
.specs__badge {
background: var(--dekra-vert);
color: #fff;
border-radius: 999px;
padding: 0 0.4rem;
font-size: 0.72rem;
line-height: 1.5;
}
.specs__info,
.specs__erreur {
font-size: 0.95rem;
}
.specs__erreur {
color: var(--ds-danger-500);
}
/* Bouton « annoter » accolé aux titres (injecté dans le HTML rendu). */
.specs__markdown :is(h2, h3, h4) .btn-annoter {
border: none;
background: transparent;
cursor: pointer;
font-size: 0.85em;
opacity: 0;
transition: opacity 0.12s ease-in-out;
padding: 0 0.25rem;
}
.specs__markdown :is(h2, h3, h4):hover .btn-annoter {
opacity: 0.65;
}
.specs__markdown .btn-annoter:hover {
opacity: 1;
}
/* ----- Panneau latéral des annotations ----- */
.annot {
background: #fff;
border-left: 1px solid var(--gris-bord);
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
/* Suit le défilement de la page : reste visible en haut de la fenêtre. */
position: sticky;
top: 0;
align-self: start;
height: calc(100vh - 56px); /* 56px = hauteur de l'en-tête */
overflow: hidden; /* le scroll est délégué à .annot__liste */
}
/* Zone haute figée : titre, export, formulaire, filtre. Ne défile pas. */
.annot__haut {
flex: 0 0 auto;
display: flex;
flex-direction: column;
gap: 0.6rem;
padding-bottom: 0.6rem;
border-bottom: 1px solid var(--ds-gray-200);
}
.annot__entete {
display: flex;
align-items: center;
justify-content: space-between;
}
.annot__titre {
margin: 0;
font-size: 1.1rem;
color: var(--dekra-vert-fonce);
}
.annot__fermer {
border: none;
background: transparent;
cursor: pointer;
font-size: 1rem;
color: var(--gris-doux);
}
.annot__export {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.8rem;
color: var(--gris-doux);
flex-wrap: wrap;
}
.annot__export button {
border: 1px solid var(--gris-bord);
background: #fff;
border-radius: 6px;
padding: 0.25rem 0.5rem;
cursor: pointer;
font-size: 0.8rem;
color: var(--dekra-vert-fonce);
}
.annot__export button:hover {
background: #eef7f0;
}
.annot__form-bloc {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.annot__form-toggle {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
text-align: left;
background: var(--ds-dark-green-50);
border: 1px solid var(--ds-gray-200);
border-radius: 8px;
padding: 0.5rem 0.7rem;
cursor: pointer;
font-size: 0.9rem;
font-weight: 600;
color: var(--ds-dark-green-700);
}
.annot__form-toggle:hover {
background: var(--ds-dark-green-100);
}
.annot__form-chevron {
font-size: 0.8rem;
}
.annot__form {
border: 1px solid var(--gris-bord);
border-radius: 8px;
padding: 0.75rem;
background: var(--gris-fond);
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.annot__form-cible {
margin: 0;
font-size: 0.82rem;
color: var(--gris-doux);
}
.annot__champ {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.82rem;
}
.annot__champ select,
.annot__champ textarea {
border: 1px solid var(--gris-bord);
border-radius: 6px;
padding: 0.4rem;
font-size: 0.88rem;
font-family: inherit;
}
.annot__champ textarea {
resize: vertical;
min-height: 11rem; /* zone de saisie 2x plus haute */
}
.annot__form-actions {
display: flex;
gap: 0.5rem;
}
.annot__valider {
background: var(--dekra-vert);
color: #fff;
border: none;
border-radius: 6px;
padding: 0.45rem 0.8rem;
cursor: pointer;
font-size: 0.85rem;
}
.annot__valider:disabled {
opacity: 0.6;
cursor: default;
}
.annot__annuler {
background: #fff;
border: 1px solid var(--gris-bord);
border-radius: 6px;
padding: 0.45rem 0.8rem;
cursor: pointer;
font-size: 0.85rem;
}
.annot__hint {
margin: 0;
font-size: 0.74rem;
color: var(--gris-doux);
}
.annot__erreur {
margin: 0;
color: var(--ds-danger-500);
font-size: 0.8rem;
}
.annot__liste {
display: flex;
flex-direction: column;
gap: 0.6rem;
/* Seule la liste défile, sous la zone haute figée. */
flex: 1 1 auto;
overflow-y: auto;
padding-top: 0.6rem;
}
.annot__filtre {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
flex-wrap: wrap;
font-size: 0.8rem;
color: var(--ds-gray-600);
}
.annot__filtre-compte {
white-space: nowrap;
}
.annot__toggle {
display: inline-flex;
align-items: center;
gap: 0.35rem;
cursor: pointer;
user-select: none;
}
.annot__toggle input {
cursor: pointer;
}
.annot__vide {
color: var(--gris-doux);
font-size: 0.85rem;
}
.annot__lecture-seule {
font-size: 0.84rem;
color: var(--ds-gray-600);
background: var(--ds-gray-50);
border: 1px solid var(--ds-gray-200);
border-radius: 7px;
padding: 0.6rem 0.75rem;
}
.annot__item {
border: 1px solid var(--gris-bord);
border-left: 3px solid var(--dekra-vert-clair);
border-radius: 7px;
padding: 0.6rem;
}
.annot__item--traitee {
opacity: 0.6;
border-left-color: var(--gris-doux);
}
.annot__item-tete {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.3rem;
}
.annot__type {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.05rem 0.4rem;
border-radius: 999px;
color: #fff;
background: var(--gris-doux);
}
.annot__type--ajout { background: var(--ds-success-500); }
.annot__type--correction { background: var(--ds-warning-400); }
.annot__type--question { background: var(--ds-info-500); }
.annot__type--remarque { background: var(--ds-dark-green-500); }
.annot__auteur {
font-size: 0.8rem;
color: var(--gris-doux);
}
.annot__statut {
margin-left: auto;
font-size: 0.72rem;
color: var(--dekra-vert-fonce);
}
.annot__section {
font-size: 0.76rem;
color: var(--gris-doux);
margin-bottom: 0.25rem;
}
.annot__texte {
margin: 0.25rem 0;
font-size: 0.88rem;
white-space: pre-wrap;
}
.annot__actions {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
margin-top: 0.4rem;
}
.annot__actions button {
border: 1px solid var(--gris-bord);
background: #fff;
border-radius: 6px;
padding: 0.25rem 0.5rem;
cursor: pointer;
font-size: 0.78rem;
color: var(--gris-texte);
}
.annot__actions button:hover {
background: var(--gris-fond);
}
.annot__suppr {
color: var(--ds-danger-500) !important;
}
@media (max-width: 980px) {
.specs,
.specs:has(.annot) {
grid-template-columns: 1fr;
}
/* En colonne unique, on désactive le sticky/hauteur plein écran. */
.specs__menu,
.annot {
position: static;
height: auto;
}
.specs__menu {
border-right: none;
border-bottom: 1px solid var(--gris-bord);
}
.annot {
border-left: none;
border-top: 1px solid var(--gris-bord);
}
}
@@ -0,0 +1,511 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
OnDestroy,
OnInit,
ViewChild,
computed,
inject,
signal
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink, RouterLinkActive } from '@angular/router';
import { ViewportScroller } from '@angular/common';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { Subscription } from 'rxjs';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { SpecDocument, SpecService } from '../../core/services/spec.service';
import { AuthService } from '../../core/services/auth.service';
import {
Annotation,
AnnotationService,
StatutAnnotation,
TypeAnnotation
} from '../../core/services/annotation.service';
/**
* `SpecsComponent` — consultation des specs (Markdown rendu en direct) + annotations.
*
* Chaque titre de section reçoit un identifiant (slug) et un bouton « 💬 » permettant
* d'annoter la section. Les annotations sont listées dans un panneau latéral droit ;
* leur création/édition/suppression passe par l'API (droits : auteur ou admin).
*/
@Component({
selector: 'app-specs',
standalone: true,
imports: [FormsModule, RouterLink, RouterLinkActive],
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './specs.component.html',
styleUrl: './specs.component.scss'
})
export class SpecsComponent implements OnInit, OnDestroy {
private readonly specService = inject(SpecService);
private readonly annotationService = inject(AnnotationService);
private readonly auth = inject(AuthService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly scroller = inject(ViewportScroller);
private readonly sanitizer = inject(DomSanitizer);
private readonly cdr = inject(ChangeDetectorRef);
@ViewChild('markdownHost') private markdownHost?: ElementRef<HTMLElement>;
readonly documents = signal<SpecDocument[]>([]);
readonly cleCourante = signal<string | null>(null);
readonly titreCourant = signal<string>('');
readonly contenuHtml = signal<SafeHtml | null>(null);
readonly chargement = signal(false);
readonly erreur = signal<string | null>(null);
// --- Annotations ---
readonly annotations = signal<Annotation[]>([]);
readonly panneauOuvert = signal(false);
readonly login = this.auth.login;
readonly estAdmin = this.auth.estAdmin;
/** L'utilisateur peut-il annoter (admin/contributeur) ? Sinon lecture seule. */
readonly peutContribuer = this.auth.peutContribuer;
/** Nombre d'annotations ouvertes sur le document courant (badge). */
readonly nbOuvertes = computed(
() => this.annotations().filter((a) => a.statut === 'ouverte').length
);
/** Nombre d'annotations traitées sur le document courant. */
readonly nbTraitees = computed(
() => this.annotations().filter((a) => a.statut === 'traitee').length
);
/** Filtre d'affichage : masquer les annotations traitées. */
readonly masquerTraitees = signal(false);
/** Repli du formulaire de saisie/édition (pour agrandir la liste). */
readonly formReplie = signal(false);
/** Annotations réellement affichées dans la liste, selon le filtre. */
readonly annotationsAffichees = computed(() =>
this.masquerTraitees()
? this.annotations().filter((a) => a.statut !== 'traitee')
: this.annotations()
);
// Formulaire de création/édition
readonly editionId = signal<string | null>(null); // null = création
readonly fTexte = signal('');
readonly fType = signal<TypeAnnotation>('remarque');
readonly fAncre = signal('');
readonly fTitreSection = signal('');
readonly envoiEnCours = signal(false);
readonly erreurForm = signal<string | null>(null);
private listeSub?: Subscription;
private contenuSub?: Subscription;
private paramSub?: Subscription;
private readonly souscriptions: Subscription[] = [];
private readonly markedInstance = this.creerMarked();
/**
* Gestionnaire de clic délégué sur le contenu rendu :
* - boutons « annoter » (.btn-annoter) -> ouvre le formulaire d'annotation ;
* - liens internes vers un autre document (.lien-interne) -> navigation SPA ;
* - liens d'ancre pure (#section) -> défilement vers la section.
*/
private readonly onClicContenu = (ev: Event) => {
const el = ev.target as HTMLElement;
// 1) Bouton d'annotation
const btn = el?.closest('.btn-annoter') as HTMLElement | null;
if (btn) {
ev.preventDefault();
this.ouvrirCreation(btn.getAttribute('data-ancre') || '', btn.getAttribute('data-titre') || '');
return;
}
// 2) Lien (document interne ou ancre)
const lien = el?.closest('a') as HTMLAnchorElement | null;
if (!lien) {
return;
}
// Lien interne vers un autre document de specs (réécrit par le renderer).
const cleCible = lien.getAttribute('data-cle');
if (cleCible) {
ev.preventDefault();
const fragment = lien.getAttribute('data-fragment') || undefined;
void this.router.navigate(['/specs', cleCible], { fragment });
if (fragment) {
setTimeout(() => this.scroller.scrollToAnchor(fragment), 50);
} else {
this.scroller.scrollToPosition([0, 0]);
}
return;
}
// Lien d'ancre pure (#id) dans le document courant.
const href = lien.getAttribute('href') || '';
if (href.startsWith('#')) {
ev.preventDefault();
this.scroller.scrollToAnchor(href.slice(1));
}
};
ngOnInit(): void {
this.listeSub = this.specService.lister().subscribe({
next: (docs) => {
this.documents.set(docs);
this.cdr.markForCheck();
},
error: () => {
this.erreur.set('Impossible de charger la liste des documents.');
this.cdr.markForCheck();
}
});
this.paramSub = this.route.paramMap.subscribe((params) => {
const cle = params.get('cle');
if (cle) {
this.chargerDocument(cle);
this.chargerAnnotations(cle);
}
});
}
ngOnDestroy(): void {
this.listeSub?.unsubscribe();
this.contenuSub?.unsubscribe();
this.paramSub?.unsubscribe();
this.souscriptions.forEach((s) => s.unsubscribe());
this.markdownHost?.nativeElement.removeEventListener('click', this.onClicContenu);
}
rafraichir(): void {
const cle = this.cleCourante();
if (cle) {
this.chargerDocument(cle);
this.chargerAnnotations(cle);
}
}
// ------------------------------------------------------------------ Document
private chargerDocument(cle: string): void {
this.contenuSub?.unsubscribe();
this.cleCourante.set(cle);
this.chargement.set(true);
this.erreur.set(null);
this.contenuHtml.set(null);
this.cdr.markForCheck();
this.contenuSub = this.specService.obtenir(cle).subscribe({
next: (doc) => {
this.titreCourant.set(doc.titre);
const html = this.markedInstance.parse(doc.markdown, { async: false }) as string;
const propre = DOMPurify.sanitize(html, {
ADD_ATTR: [
'class',
'id',
'data-ancre',
'data-titre',
'data-cle',
'data-fragment',
'title',
'type',
'target',
'rel'
]
});
this.contenuHtml.set(this.sanitizer.bypassSecurityTrustHtml(propre));
this.chargement.set(false);
this.cdr.markForCheck();
setTimeout(() => {
void this.rendreMermaid();
// (Ré)attache le gestionnaire de clic délégué sur le conteneur injecté.
const hote = this.markdownHost?.nativeElement;
if (hote) {
hote.removeEventListener('click', this.onClicContenu);
hote.addEventListener('click', this.onClicContenu);
}
}, 0);
},
error: () => {
this.chargement.set(false);
this.erreur.set("Ce document est introuvable ou n'a pas pu être chargé.");
this.cdr.markForCheck();
}
});
}
// --------------------------------------------------------------- Annotations
private chargerAnnotations(cle: string): void {
const sub = this.annotationService.lister(cle).subscribe({
next: (liste) => {
this.annotations.set(liste);
this.cdr.markForCheck();
},
error: () => {
// Silencieux : l'absence d'annotations ne doit pas bloquer la lecture.
this.annotations.set([]);
this.cdr.markForCheck();
}
});
this.souscriptions.push(sub);
}
basculerPanneau(): void {
this.panneauOuvert.update((v) => !v);
}
/** Annotations à afficher pour une ancre donnée (regroupées sous chaque section). */
annotationsPourAncre(ancre: string): Annotation[] {
return this.annotations().filter((a) => a.ancre === ancre);
}
/** Ouvre le formulaire en création pour une section. */
ouvrirCreation(ancre: string, titreSection: string): void {
this.editionId.set(null);
this.fTexte.set('');
this.fType.set('remarque');
this.fAncre.set(ancre);
this.fTitreSection.set(titreSection);
this.erreurForm.set(null);
this.panneauOuvert.set(true);
this.formReplie.set(false); // déplie le formulaire pour saisir
this.cdr.markForCheck();
}
/** Ouvre le formulaire en édition d'une annotation existante. */
ouvrirEdition(a: Annotation): void {
this.editionId.set(a.id);
this.fTexte.set(a.texte);
this.fType.set(a.type);
this.fAncre.set(a.ancre);
this.fTitreSection.set(a.titreSection);
this.erreurForm.set(null);
this.panneauOuvert.set(true);
this.formReplie.set(false); // déplie le formulaire pour éditer
this.cdr.markForCheck();
}
annulerForm(): void {
this.editionId.set(null);
this.fTexte.set('');
this.erreurForm.set(null);
}
/** Droit de modifier/supprimer : admin, ou auteur ayant un accès contributeur. */
peutModifier(a: Annotation): boolean {
if (this.estAdmin()) {
return true;
}
return this.peutContribuer() && a.auteur === this.login();
}
soumettreForm(): void {
const cle = this.cleCourante();
if (!cle) {
return;
}
const texte = this.fTexte().trim();
if (!texte) {
this.erreurForm.set('Le texte est requis.');
return;
}
this.envoiEnCours.set(true);
this.erreurForm.set(null);
const id = this.editionId();
if (id) {
// Édition
const sub = this.annotationService
.mettreAJour(cle, id, { texte, type: this.fType() })
.subscribe({
next: (maj) => {
this.annotations.update((l) => l.map((a) => (a.id === maj.id ? maj : a)));
this.envoiEnCours.set(false);
this.annulerForm();
this.cdr.markForCheck();
},
error: () => {
this.envoiEnCours.set(false);
this.erreurForm.set("Échec de l'enregistrement.");
this.cdr.markForCheck();
}
});
this.souscriptions.push(sub);
} else {
// Création
const sub = this.annotationService
.creer(cle, {
texte,
type: this.fType(),
ancre: this.fAncre(),
titreSection: this.fTitreSection()
})
.subscribe({
next: (creee) => {
this.annotations.update((l) => [...l, creee]);
this.envoiEnCours.set(false);
this.annulerForm();
this.cdr.markForCheck();
},
error: () => {
this.envoiEnCours.set(false);
this.erreurForm.set("Échec de l'enregistrement.");
this.cdr.markForCheck();
}
});
this.souscriptions.push(sub);
}
}
basculerStatut(a: Annotation): void {
const cle = this.cleCourante();
if (!cle) {
return;
}
const nouveau: StatutAnnotation = a.statut === 'ouverte' ? 'traitee' : 'ouverte';
const sub = this.annotationService.mettreAJour(cle, a.id, { statut: nouveau }).subscribe({
next: (maj) => {
this.annotations.update((l) => l.map((x) => (x.id === maj.id ? maj : x)));
this.cdr.markForCheck();
},
error: () => this.cdr.markForCheck()
});
this.souscriptions.push(sub);
}
supprimer(a: Annotation): void {
const cle = this.cleCourante();
if (!cle) {
return;
}
const sub = this.annotationService.supprimer(cle, a.id).subscribe({
next: () => {
this.annotations.update((l) => l.filter((x) => x.id !== a.id));
this.cdr.markForCheck();
},
error: () => this.cdr.markForCheck()
});
this.souscriptions.push(sub);
}
// L'export des annotations est désormais accessible uniquement depuis l'en-tête du
// site (composant racine), pour l'administrateur. Il a donc été retiré de ce panneau.
// ------------------------------------------------------------------- Mermaid
private async rendreMermaid(): Promise<void> {
const hote = this.markdownHost?.nativeElement;
if (!hote) {
return;
}
const blocs = hote.querySelectorAll<HTMLElement>('pre.mermaid');
if (blocs.length === 0) {
return;
}
try {
const mermaid = (await import('mermaid')).default;
mermaid.initialize({ startOnLoad: false, theme: 'default', securityLevel: 'loose' });
await mermaid.run({ nodes: Array.from(blocs) });
} catch {
// Repli : le bloc de code brut reste visible si le rendu échoue.
}
}
// -------------------------------------------------------------------- marked
private creerMarked(): typeof marked {
const slugs = this.compteurSlugs();
// Lu à chaque rendu : masque le bouton « annoter » pour les comptes en lecture seule.
const peutContribuer = () => this.peutContribuer();
marked.use({
renderer: {
code(this: unknown, { text, lang }: { text: string; lang?: string }): string {
if ((lang ?? '').trim().toLowerCase() === 'mermaid') {
return `<pre class="mermaid">${SpecsComponent.echapper(text)}</pre>`;
}
const langue = (lang ?? '').trim();
const classe = langue ? ` class="language-${SpecsComponent.echapper(langue)}"` : '';
return `<pre><code${classe}>${SpecsComponent.echapper(text)}</code></pre>`;
},
link({ href, title, text }: { href: string; title?: string | null; text: string }): string {
// `text` est le libellé déjà rendu (HTML inline) du lien.
const texte = text;
const t = title ? ` title="${SpecsComponent.echapper(title)}"` : '';
// Lien interne vers un autre document de specs : "06-prestations.md" ou
// "06-prestations.md#4-1" ou "./06-prestations.md". On réécrit en route SPA.
const interne = /^(?:\.\/)?([a-z0-9][a-z0-9-]*)\.md(?:#([^)]*))?$/i.exec(href || '');
if (interne) {
const cle = interne[1];
const fragment = interne[2] || '';
const dataFragment = fragment ? ` data-fragment="${SpecsComponent.echapper(fragment)}"` : '';
return `<a href="/specs/${SpecsComponent.echapper(cle)}" class="lien-interne" data-cle="${SpecsComponent.echapper(
cle
)}"${dataFragment}${t}>${texte}</a>`;
}
// Lien externe : ouvre dans un nouvel onglet, sécurisé.
const estExterne = /^https?:\/\//i.test(href || '');
const rel = estExterne ? ' target="_blank" rel="noopener noreferrer"' : '';
return `<a href="${SpecsComponent.echapper(href || '#')}"${t}${rel}>${texte}</a>`;
},
heading({ text, depth, tokens }: { text: string; depth: number; tokens?: unknown }): string {
// `text` peut contenir du HTML inline déjà rendu ; on dérive un libellé propre.
const brut = SpecsComponent.texteBrut(text);
const ancre = slugs(brut);
// Bouton d'annotation : pas sur le H1 (titre du document), et seulement si
// l'utilisateur peut contribuer (masqué en lecture seule).
const bouton =
depth === 1 || !peutContribuer()
? ''
: ` <button type="button" class="btn-annoter" data-ancre="${SpecsComponent.echapper(
ancre
)}" data-titre="${SpecsComponent.echapper(brut)}" title="Annoter cette section">💬</button>`;
return `<h${depth} id="${SpecsComponent.echapper(ancre)}">${text}${bouton}</h${depth}>`;
}
}
});
return marked;
}
/** Fabrique un générateur de slugs uniques (gère les doublons par suffixe -n). */
private compteurSlugs(): (texte: string) => string {
const vus = new Map<string, number>();
return (texte: string) => {
const base =
texte
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '') // accents
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'section';
const n = vus.get(base) ?? 0;
vus.set(base, n + 1);
return n === 0 ? base : `${base}-${n}`;
};
}
private static texteBrut(html: string): string {
return html
.replace(/<[^>]+>/g, '')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.trim();
}
private static echapper(valeur: string): string {
return valeur
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
}