Initial commit — specs pré-projet SatPRO + site de présentation (viewer, annotations, gestion des accès)
This commit is contained in:
@@ -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(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.trim();
|
||||
}
|
||||
|
||||
private static echapper(valeur: string): string {
|
||||
return valeur
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user