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,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;');
}
}