feat: initial scaffold — MVP catalogue de données d'API
- Backend NestJS : CRUD api_entries + categories, upload YAML multi-fichiers, génération docs AsyncAPI (@asyncapi/cli@6.0.0) et OpenAPI (redocly) - Fix: route wildcard GET /api/apis/:id/*path pour servir les assets statiques (CSS/JS) générés par AsyncAPI HTML template (contournement bug path-to-regexp v8) - Frontend Angular 19 : pages catalog, browse, api-detail, doc-viewer, upload (DSFR) - Seeder de données de démo (idempotent) - Docker Compose dev + Dockerfiles + manifests K8s - Documentation : README, architecture, référence API REST Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
74
front-public/angular.json
Normal file
74
front-public/angular.json
Normal file
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"front-public": {
|
||||
"projectType": "application",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "scss",
|
||||
"standalone": true,
|
||||
"changeDetection": "OnPush"
|
||||
}
|
||||
},
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:application",
|
||||
"options": {
|
||||
"outputPath": "dist/front-public",
|
||||
"index": "src/index.html",
|
||||
"browser": "src/main.ts",
|
||||
"polyfills": ["zone.js"],
|
||||
"tsConfig": "tsconfig.json",
|
||||
"assets": [
|
||||
"src/favicon.ico",
|
||||
"src/assets",
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "node_modules/@gouvfr/dsfr/dist/dsfr",
|
||||
"output": "assets/dsfr"
|
||||
},
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "node_modules/@gouvfr/dsfr/dist/utility",
|
||||
"output": "assets/dsfr/utility"
|
||||
}
|
||||
],
|
||||
"styles": ["src/styles.scss"],
|
||||
"scripts": []
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"budgets": [
|
||||
{ "type": "initial", "maximumWarning": "500kB", "maximumError": "1MB" },
|
||||
{ "type": "anyComponentStyle", "maximumWarning": "4kB", "maximumError": "8kB" }
|
||||
],
|
||||
"outputHashing": "all"
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"configurations": {
|
||||
"production": { "buildTarget": "front-public:build:production" },
|
||||
"development": { "buildTarget": "front-public:build:development" }
|
||||
},
|
||||
"defaultConfiguration": "development",
|
||||
"options": {
|
||||
"proxyConfig": "proxy.conf.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
34
front-public/package.json
Normal file
34
front-public/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@datacat/front-public",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"build:prod": "ng build --configuration production",
|
||||
"test": "ng test --watch=false --browsers=ChromeHeadless",
|
||||
"lint": "ng lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@angular/animations": "^19.0.0",
|
||||
"@angular/common": "^19.0.0",
|
||||
"@angular/compiler": "^19.0.0",
|
||||
"@angular/core": "^19.0.0",
|
||||
"@angular/forms": "^19.0.0",
|
||||
"@angular/platform-browser": "^19.0.0",
|
||||
"@angular/platform-browser-dynamic": "^19.0.0",
|
||||
"@angular/router": "^19.0.0",
|
||||
"rxjs": "^7.8.0",
|
||||
"tslib": "^2.8.0",
|
||||
"zone.js": "^0.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^19.0.0",
|
||||
"@angular/cli": "^19.0.0",
|
||||
"@angular/compiler-cli": "^19.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@gouvfr/dsfr": "^1.13.0",
|
||||
"typescript": "~5.8.3",
|
||||
"sass": "^1.83.0"
|
||||
}
|
||||
}
|
||||
6
front-public/proxy.conf.docker.json
Normal file
6
front-public/proxy.conf.docker.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"/api": {
|
||||
"target": "http://backend:3000",
|
||||
"secure": false
|
||||
}
|
||||
}
|
||||
6
front-public/proxy.conf.json
Normal file
6
front-public/proxy.conf.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"/api": {
|
||||
"target": "http://localhost:3000",
|
||||
"secure": false
|
||||
}
|
||||
}
|
||||
63
front-public/src/app/app.component.ts
Normal file
63
front-public/src/app/app.component.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
standalone: true,
|
||||
imports: [RouterOutlet, RouterLink, RouterLinkActive],
|
||||
template: `
|
||||
<header role="banner" class="fr-header">
|
||||
<div class="fr-header__body">
|
||||
<div class="fr-container">
|
||||
<div class="fr-header__body-row">
|
||||
<div class="fr-header__brand fr-enlarge-link">
|
||||
<div class="fr-header__brand-top">
|
||||
<div class="fr-header__logo">
|
||||
<p class="fr-logo">République<br />Française</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fr-header__service">
|
||||
<a href="/" routerLink="/browse" title="Accueil — Datacat">
|
||||
<p class="fr-header__service-title">Datacat</p>
|
||||
</a>
|
||||
<p class="fr-header__service-tagline">Catalogue de données d'API</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fr-header__menu">
|
||||
<div class="fr-container">
|
||||
<nav class="fr-nav" role="navigation" aria-label="Menu principal">
|
||||
<ul class="fr-nav__list">
|
||||
<li class="fr-nav__item">
|
||||
<a
|
||||
class="fr-nav__link"
|
||||
routerLink="/browse"
|
||||
routerLinkActive="fr-nav__link--active"
|
||||
[routerLinkActiveOptions]="{ exact: false }"
|
||||
>
|
||||
Parcourir
|
||||
</a>
|
||||
</li>
|
||||
<li class="fr-nav__item">
|
||||
<a
|
||||
class="fr-nav__link"
|
||||
routerLink="/catalog"
|
||||
routerLinkActive="fr-nav__link--active"
|
||||
[routerLinkActiveOptions]="{ exact: false }"
|
||||
>
|
||||
Catalogue
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<router-outlet />
|
||||
</main>
|
||||
`,
|
||||
})
|
||||
export class AppComponent {}
|
||||
12
front-public/src/app/app.config.ts
Normal file
12
front-public/src/app/app.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
|
||||
import { provideRouter, withComponentInputBinding } from '@angular/router';
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||
import { routes } from './app.routes';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideZoneChangeDetection({ eventCoalescing: true }),
|
||||
provideRouter(routes, withComponentInputBinding()),
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
],
|
||||
};
|
||||
43
front-public/src/app/app.routes.ts
Normal file
43
front-public/src/app/app.routes.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'browse',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'browse',
|
||||
loadComponent: () =>
|
||||
import('./pages/browse/browse.component').then((m) => m.BrowseComponent),
|
||||
},
|
||||
{
|
||||
path: 'browse/:id',
|
||||
loadComponent: () =>
|
||||
import('./pages/browse/browse.component').then((m) => m.BrowseComponent),
|
||||
},
|
||||
{
|
||||
path: 'catalog',
|
||||
loadComponent: () =>
|
||||
import('./pages/catalog/catalog.component').then((m) => m.CatalogComponent),
|
||||
},
|
||||
{
|
||||
path: 'catalog/:id',
|
||||
loadComponent: () =>
|
||||
import('./pages/api-detail/api-detail.component').then((m) => m.ApiDetailComponent),
|
||||
},
|
||||
{
|
||||
path: 'catalog/:id/docs',
|
||||
loadComponent: () =>
|
||||
import('./pages/doc-viewer/doc-viewer.component').then((m) => m.DocViewerComponent),
|
||||
},
|
||||
{
|
||||
path: 'upload',
|
||||
loadComponent: () =>
|
||||
import('./pages/upload/upload.component').then((m) => m.UploadComponent),
|
||||
},
|
||||
{
|
||||
path: '**',
|
||||
redirectTo: 'browse',
|
||||
},
|
||||
];
|
||||
0
front-public/src/app/core/.gitkeep
Normal file
0
front-public/src/app/core/.gitkeep
Normal file
40
front-public/src/app/core/api.service.ts
Normal file
40
front-public/src/app/core/api.service.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { ApiEntry, ApiListResponse, ApiListQuery } from '@datacat/shared';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ApiService {
|
||||
private http = inject(HttpClient);
|
||||
private baseUrl = '/api/apis';
|
||||
|
||||
list(query?: ApiListQuery): Observable<ApiListResponse> {
|
||||
const params: Record<string, string> = {};
|
||||
if (query?.search) params['search'] = query.search;
|
||||
if (query?.type) params['type'] = query.type;
|
||||
if (query?.categoryId) params['categoryId'] = query.categoryId;
|
||||
if (query?.page) params['page'] = String(query.page);
|
||||
if (query?.limit) params['limit'] = String(query.limit);
|
||||
return this.http.get<ApiListResponse>(this.baseUrl, { params });
|
||||
}
|
||||
|
||||
get(id: string): Observable<ApiEntry> {
|
||||
return this.http.get<ApiEntry>(`${this.baseUrl}/${id}`);
|
||||
}
|
||||
|
||||
update(id: string, data: { title?: string; version?: string; description?: string }): Observable<ApiEntry> {
|
||||
return this.http.patch<ApiEntry>(`${this.baseUrl}/${id}`, data);
|
||||
}
|
||||
|
||||
delete(id: string): Observable<void> {
|
||||
return this.http.delete<void>(`${this.baseUrl}/${id}`);
|
||||
}
|
||||
|
||||
regenerate(id: string): Observable<ApiEntry> {
|
||||
return this.http.post<ApiEntry>(`${this.baseUrl}/${id}/regenerate`, {});
|
||||
}
|
||||
|
||||
upload(formData: FormData): Observable<ApiEntry> {
|
||||
return this.http.post<ApiEntry>('/api/uploads', formData);
|
||||
}
|
||||
}
|
||||
39
front-public/src/app/core/category.service.ts
Normal file
39
front-public/src/app/core/category.service.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import {
|
||||
Category,
|
||||
CategoryListResponse,
|
||||
CategoryBrowseResponse,
|
||||
} from '@datacat/shared';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class CategoryService {
|
||||
private http = inject(HttpClient);
|
||||
private baseUrl = '/api/categories';
|
||||
|
||||
list(): Observable<CategoryListResponse> {
|
||||
return this.http.get<CategoryListResponse>(this.baseUrl);
|
||||
}
|
||||
|
||||
browse(id: string | null): Observable<CategoryBrowseResponse> {
|
||||
const url = id ? `${this.baseUrl}/browse/${id}` : `${this.baseUrl}/browse`;
|
||||
return this.http.get<CategoryBrowseResponse>(url);
|
||||
}
|
||||
|
||||
get(id: string): Observable<Category> {
|
||||
return this.http.get<Category>(`${this.baseUrl}/${id}`);
|
||||
}
|
||||
|
||||
create(data: { name: string; description?: string; parentId?: string; orderIndex?: number }): Observable<Category> {
|
||||
return this.http.post<Category>(this.baseUrl, data);
|
||||
}
|
||||
|
||||
update(id: string, data: Partial<{ name: string; description: string; parentId: string | null; orderIndex: number }>): Observable<Category> {
|
||||
return this.http.patch<Category>(`${this.baseUrl}/${id}`, data);
|
||||
}
|
||||
|
||||
delete(id: string): Observable<void> {
|
||||
return this.http.delete<void>(`${this.baseUrl}/${id}`);
|
||||
}
|
||||
}
|
||||
0
front-public/src/app/pages/api-detail/.gitkeep
Normal file
0
front-public/src/app/pages/api-detail/.gitkeep
Normal file
237
front-public/src/app/pages/api-detail/api-detail.component.ts
Normal file
237
front-public/src/app/pages/api-detail/api-detail.component.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import {
|
||||
Component,
|
||||
signal,
|
||||
inject,
|
||||
OnInit,
|
||||
OnDestroy,
|
||||
ChangeDetectionStrategy,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import { ApiService } from '../../core/api.service';
|
||||
import { ApiEntry } from '@datacat/shared';
|
||||
|
||||
@Component({
|
||||
selector: 'app-api-detail',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="fr-container fr-mt-4w fr-mb-4w">
|
||||
<!-- Fil d'Ariane -->
|
||||
<nav class="fr-breadcrumb fr-mb-3w" aria-label="vous êtes ici">
|
||||
<ol class="fr-breadcrumb__list">
|
||||
<li>
|
||||
<a class="fr-breadcrumb__link" routerLink="/catalog">Catalogue</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="fr-breadcrumb__link" aria-current="page">
|
||||
{{ api()?.title ?? 'Chargement...' }}
|
||||
</a>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="fr-callout fr-mb-4w">
|
||||
<p class="fr-callout__text">Chargement...</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (error()) {
|
||||
<div class="fr-alert fr-alert--error fr-mb-4w">
|
||||
<p class="fr-alert__title">Erreur</p>
|
||||
<p>{{ error() }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (api(); as entry) {
|
||||
<!-- En-tête -->
|
||||
<div class="fr-grid-row fr-grid-row--middle fr-mb-3w">
|
||||
<div class="fr-col">
|
||||
<h1 class="fr-h2 fr-mb-1w">{{ entry.title }}</h1>
|
||||
<div class="fr-tags-group">
|
||||
<span class="fr-tag">{{ entry.type }}</span>
|
||||
<span class="fr-tag">v{{ entry.version }}</span>
|
||||
<span [class]="badgeClass(entry.status)">{{ entry.status }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alerte statut PENDING -->
|
||||
@if (entry.status === 'PENDING') {
|
||||
<div class="fr-alert fr-alert--info fr-mb-3w">
|
||||
<p class="fr-alert__title">Génération en cours</p>
|
||||
<p>La documentation est en cours de génération. Cette page se rafraîchit automatiquement.</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Alerte statut ERROR -->
|
||||
@if (entry.status === 'ERROR') {
|
||||
<div class="fr-alert fr-alert--error fr-mb-3w">
|
||||
<p class="fr-alert__title">Erreur de génération</p>
|
||||
<p>{{ entry.errorMessage }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Métadonnées -->
|
||||
<div class="fr-card fr-mb-4w">
|
||||
<div class="fr-card__body">
|
||||
<div class="fr-card__content">
|
||||
<h2 class="fr-h5 fr-mb-2w">Informations</h2>
|
||||
<dl class="fr-grid-row fr-grid-row--gutters">
|
||||
<div class="fr-col-12 fr-col-md-4">
|
||||
<dt class="fr-text--bold">Titre</dt>
|
||||
<dd>{{ entry.title }}</dd>
|
||||
</div>
|
||||
<div class="fr-col-12 fr-col-md-4">
|
||||
<dt class="fr-text--bold">Version</dt>
|
||||
<dd>{{ entry.version }}</dd>
|
||||
</div>
|
||||
<div class="fr-col-12 fr-col-md-4">
|
||||
<dt class="fr-text--bold">Type</dt>
|
||||
<dd>{{ entry.type }}</dd>
|
||||
</div>
|
||||
@if (entry.description) {
|
||||
<div class="fr-col-12">
|
||||
<dt class="fr-text--bold">Description</dt>
|
||||
<dd>{{ entry.description }}</dd>
|
||||
</div>
|
||||
}
|
||||
<div class="fr-col-12 fr-col-md-6">
|
||||
<dt class="fr-text--bold">Créé le</dt>
|
||||
<dd>{{ entry.createdAt | date:'dd/MM/yyyy HH:mm' }}</dd>
|
||||
</div>
|
||||
<div class="fr-col-12 fr-col-md-6">
|
||||
<dt class="fr-text--bold">Mis à jour le</dt>
|
||||
<dd>{{ entry.updatedAt | date:'dd/MM/yyyy HH:mm' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="fr-btns-group fr-btns-group--inline-sm">
|
||||
<a
|
||||
[routerLink]="['/catalog', entry.id, 'docs']"
|
||||
class="fr-btn"
|
||||
[class.fr-btn--disabled]="entry.status !== 'GENERATED'"
|
||||
[attr.aria-disabled]="entry.status !== 'GENERATED' ? 'true' : null"
|
||||
>
|
||||
Voir la documentation
|
||||
</a>
|
||||
<a
|
||||
[href]="'/api/apis/' + entry.id + '/yaml'"
|
||||
class="fr-btn fr-btn--secondary"
|
||||
download
|
||||
>
|
||||
Télécharger YAML
|
||||
</a>
|
||||
<button
|
||||
class="fr-btn fr-btn--secondary"
|
||||
(click)="onRegenerate()"
|
||||
[disabled]="regenerating()"
|
||||
>
|
||||
{{ regenerating() ? 'Régénération...' : 'Régénérer' }}
|
||||
</button>
|
||||
<button
|
||||
class="fr-btn fr-btn--secondary"
|
||||
(click)="onDelete()"
|
||||
[disabled]="deleting()"
|
||||
>
|
||||
{{ deleting() ? 'Suppression...' : 'Supprimer' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class ApiDetailComponent implements OnInit, OnDestroy {
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
private apiService = inject(ApiService);
|
||||
|
||||
api = signal<ApiEntry | null>(null);
|
||||
loading = signal(false);
|
||||
error = signal<string | null>(null);
|
||||
regenerating = signal(false);
|
||||
deleting = signal(false);
|
||||
|
||||
private pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private id = '';
|
||||
|
||||
ngOnInit() {
|
||||
this.id = this.route.snapshot.paramMap.get('id') ?? '';
|
||||
this.loadApi();
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.clearPoll();
|
||||
}
|
||||
|
||||
onRegenerate() {
|
||||
this.regenerating.set(true);
|
||||
this.apiService.regenerate(this.id).subscribe({
|
||||
next: (entry) => {
|
||||
this.api.set(entry);
|
||||
this.regenerating.set(false);
|
||||
this.startPollIfPending();
|
||||
},
|
||||
error: () => this.regenerating.set(false),
|
||||
});
|
||||
}
|
||||
|
||||
onDelete() {
|
||||
if (!confirm('Supprimer cette API ?')) return;
|
||||
this.deleting.set(true);
|
||||
this.apiService.delete(this.id).subscribe({
|
||||
next: () => this.router.navigate(['/catalog']),
|
||||
error: () => this.deleting.set(false),
|
||||
});
|
||||
}
|
||||
|
||||
badgeClass(status: string): string {
|
||||
if (status === 'GENERATED') return 'fr-badge fr-badge--success';
|
||||
if (status === 'ERROR') return 'fr-badge fr-badge--error';
|
||||
return 'fr-badge fr-badge--info';
|
||||
}
|
||||
|
||||
private loadApi() {
|
||||
this.loading.set(true);
|
||||
this.apiService.get(this.id).subscribe({
|
||||
next: (entry) => {
|
||||
this.api.set(entry);
|
||||
this.loading.set(false);
|
||||
this.startPollIfPending();
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err.message ?? 'Erreur lors du chargement');
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private startPollIfPending() {
|
||||
this.clearPoll();
|
||||
if (this.api()?.status === 'PENDING') {
|
||||
this.pollInterval = setInterval(() => this.pollStatus(), 3000);
|
||||
}
|
||||
}
|
||||
|
||||
private pollStatus() {
|
||||
this.apiService.get(this.id).subscribe({
|
||||
next: (entry) => {
|
||||
this.api.set(entry);
|
||||
if (entry.status !== 'PENDING') this.clearPoll();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private clearPoll() {
|
||||
if (this.pollInterval) {
|
||||
clearInterval(this.pollInterval);
|
||||
this.pollInterval = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
171
front-public/src/app/pages/browse/browse.component.ts
Normal file
171
front-public/src/app/pages/browse/browse.component.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import {
|
||||
Component,
|
||||
signal,
|
||||
computed,
|
||||
inject,
|
||||
OnInit,
|
||||
DestroyRef,
|
||||
ChangeDetectionStrategy,
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { CategoryService } from '../../core/category.service';
|
||||
import { CategoryBrowseResponse, Category } from '@datacat/shared';
|
||||
|
||||
@Component({
|
||||
selector: 'app-browse',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="fr-container fr-mt-4w fr-mb-4w">
|
||||
<!-- Fil d'Ariane -->
|
||||
<nav class="fr-breadcrumb fr-mb-3w" aria-label="vous êtes ici">
|
||||
<ol class="fr-breadcrumb__list">
|
||||
<li>
|
||||
<a class="fr-breadcrumb__link" routerLink="/browse">Parcourir</a>
|
||||
</li>
|
||||
@for (crumb of ancestors(); track crumb.id) {
|
||||
<li>
|
||||
<a class="fr-breadcrumb__link" [routerLink]="['/browse', crumb.id]">{{ crumb.name }}</a>
|
||||
</li>
|
||||
}
|
||||
@if (data()?.category) {
|
||||
<li>
|
||||
<a class="fr-breadcrumb__link" aria-current="page">{{ data()!.category!.name }}</a>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<h1 class="fr-h1 fr-mb-4w">{{ data()?.category?.name ?? 'Parcourir le catalogue' }}</h1>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="fr-callout fr-mb-4w">
|
||||
<p class="fr-callout__text">Chargement...</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (error()) {
|
||||
<div class="fr-alert fr-alert--error fr-mb-4w">
|
||||
<p>{{ error() }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!loading() && data()) {
|
||||
<!-- Sous-catégories -->
|
||||
@if (data()!.subcategories.length > 0) {
|
||||
<h2 class="fr-h4 fr-mb-3w">Catégories</h2>
|
||||
<div class="fr-grid-row fr-grid-row--gutters fr-mb-4w">
|
||||
@for (cat of data()!.subcategories; track cat.id) {
|
||||
<div class="fr-col-12 fr-col-md-4">
|
||||
<div class="fr-tile fr-enlarge-link fr-tile--sm">
|
||||
<div class="fr-tile__body">
|
||||
<div class="fr-tile__content">
|
||||
<h3 class="fr-tile__title">
|
||||
<a [routerLink]="['/browse', cat.id]">{{ cat.name }}</a>
|
||||
</h3>
|
||||
@if (cat.description) {
|
||||
<p class="fr-tile__desc">{{ cat.description }}</p>
|
||||
}
|
||||
<p class="fr-tile__detail">
|
||||
{{ cat.subcategoryCount }} sous-catégorie(s) · {{ cat.apiCount }} API(s)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- APIs -->
|
||||
@if (data()!.apis.length > 0) {
|
||||
<h2 class="fr-h4 fr-mb-3w">APIs</h2>
|
||||
<div class="fr-grid-row fr-grid-row--gutters">
|
||||
@for (api of data()!.apis; track api.id) {
|
||||
<div class="fr-col-12 fr-col-md-4">
|
||||
<div class="fr-tile fr-enlarge-link fr-tile--sm">
|
||||
<div class="fr-tile__body">
|
||||
<div class="fr-tile__content">
|
||||
<h3 class="fr-tile__title">
|
||||
<a [routerLink]="['/catalog', api.id]">{{ api.title }}</a>
|
||||
</h3>
|
||||
@if (api.description) {
|
||||
<p class="fr-tile__desc">{{ api.description }}</p>
|
||||
}
|
||||
<p class="fr-tile__detail">
|
||||
<span [class]="typeBadgeClass(api.type)">{{ api.type }}</span>
|
||||
|
||||
<span [class]="statusBadgeClass(api.status)">{{ api.status }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (data()!.subcategories.length === 0 && data()!.apis.length === 0) {
|
||||
<div class="fr-callout fr-callout--blue-cumulus fr-mb-4w">
|
||||
<p class="fr-callout__text">Aucun contenu dans cette catégorie.</p>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class BrowseComponent implements OnInit {
|
||||
private route = inject(ActivatedRoute);
|
||||
private categoryService = inject(CategoryService);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
data = signal<CategoryBrowseResponse | null>(null);
|
||||
loading = signal(false);
|
||||
error = signal<string | null>(null);
|
||||
|
||||
/* Ancêtres = breadcrumb sans le nœud courant (déjà dans le h1) */
|
||||
ancestors = computed<Category[]>(() => {
|
||||
const d = this.data();
|
||||
if (!d || !d.category) return [];
|
||||
return d.breadcrumb.slice(0, -1);
|
||||
});
|
||||
|
||||
ngOnInit() {
|
||||
this.route.paramMap
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((params) => {
|
||||
this.loadBrowse(params.get('id'));
|
||||
});
|
||||
}
|
||||
|
||||
private loadBrowse(id: string | null) {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
this.categoryService.browse(id).subscribe({
|
||||
next: (data) => {
|
||||
this.data.set(data);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.error.set('Erreur lors du chargement des données.');
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
typeBadgeClass(type: string): string {
|
||||
return type === 'ASYNCAPI'
|
||||
? 'fr-badge fr-badge--sm fr-badge--purple-glycine'
|
||||
: 'fr-badge fr-badge--sm fr-badge--blue-ecume';
|
||||
}
|
||||
|
||||
statusBadgeClass(status: string): string {
|
||||
if (status === 'GENERATED') return 'fr-badge fr-badge--sm fr-badge--success';
|
||||
if (status === 'ERROR') return 'fr-badge fr-badge--sm fr-badge--error';
|
||||
return 'fr-badge fr-badge--sm fr-badge--info';
|
||||
}
|
||||
}
|
||||
0
front-public/src/app/pages/catalog/.gitkeep
Normal file
0
front-public/src/app/pages/catalog/.gitkeep
Normal file
232
front-public/src/app/pages/catalog/catalog.component.ts
Normal file
232
front-public/src/app/pages/catalog/catalog.component.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import {
|
||||
Component,
|
||||
signal,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
OnInit,
|
||||
ChangeDetectionStrategy,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ApiService } from '../../core/api.service';
|
||||
import { ApiEntryListItem, ApiType } from '@datacat/shared';
|
||||
|
||||
@Component({
|
||||
selector: 'app-catalog',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink, FormsModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="fr-container fr-mt-4w fr-mb-4w">
|
||||
<!-- En-tête page -->
|
||||
<div class="fr-grid-row fr-grid-row--middle fr-mb-4w">
|
||||
<div class="fr-col">
|
||||
<h1 class="fr-h2 fr-mb-0">Catalogue des APIs</h1>
|
||||
</div>
|
||||
<div class="fr-col-auto">
|
||||
<a routerLink="/upload" class="fr-btn fr-btn--icon-left fr-icon-upload-2-line">
|
||||
Importer une API
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filtres -->
|
||||
<div class="fr-grid-row fr-grid-row--gutters fr-grid-row--middle fr-mb-4w">
|
||||
<div class="fr-col-12 fr-col-md-8">
|
||||
<div class="fr-search-bar" role="search">
|
||||
<label class="fr-label" for="search-input">Recherche</label>
|
||||
<input
|
||||
id="search-input"
|
||||
class="fr-input"
|
||||
type="search"
|
||||
placeholder="Rechercher par titre ou description..."
|
||||
[ngModel]="search()"
|
||||
(ngModelChange)="onSearchChange($event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fr-col-12 fr-col-md-4">
|
||||
<label class="fr-sr-only" for="type-filter">Type</label>
|
||||
<select
|
||||
id="type-filter"
|
||||
class="fr-select"
|
||||
[ngModel]="typeFilter()"
|
||||
(ngModelChange)="onTypeChange($event)"
|
||||
>
|
||||
<option value="">Tous les types</option>
|
||||
<option value="ASYNCAPI">AsyncAPI</option>
|
||||
<option value="OPENAPI">OpenAPI</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chargement -->
|
||||
@if (loading()) {
|
||||
<div class="fr-callout fr-mb-4w">
|
||||
<p class="fr-callout__text">Chargement...</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Liste vide -->
|
||||
@if (!loading() && apis().length === 0) {
|
||||
<div class="fr-callout fr-callout--blue-cumulus fr-mb-4w">
|
||||
<p class="fr-callout__title">Aucune API trouvée</p>
|
||||
<p class="fr-callout__text">
|
||||
Importez votre première API en cliquant sur "Importer une API".
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Grille de cartes -->
|
||||
<div class="fr-grid-row fr-grid-row--gutters">
|
||||
@for (api of apis(); track api.id) {
|
||||
<div class="fr-col-12 fr-col-md-6 fr-col-lg-4">
|
||||
<div class="fr-card fr-card--shadow fr-enlarge-link">
|
||||
<div class="fr-card__body">
|
||||
<div class="fr-card__content">
|
||||
<h3 class="fr-card__title">
|
||||
<a [routerLink]="['/catalog', api.id]">{{ api.title }}</a>
|
||||
</h3>
|
||||
<p class="fr-card__desc">{{ api.description || 'Pas de description' }}</p>
|
||||
<div class="fr-card__start">
|
||||
<ul class="fr-tags-group">
|
||||
<li>
|
||||
<p class="fr-tag">{{ api.type }}</p>
|
||||
</li>
|
||||
<li>
|
||||
<p class="fr-tag">v{{ api.version }}</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fr-card__footer">
|
||||
<span [class]="badgeClass(api.status)" aria-hidden="true">
|
||||
{{ api.status }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
@if (totalPages() > 1) {
|
||||
<nav class="fr-pagination fr-mt-4w" role="navigation" aria-label="Pagination">
|
||||
<ul class="fr-pagination__list">
|
||||
<li>
|
||||
<button
|
||||
class="fr-pagination__link fr-pagination__link--prev fr-pagination__link--lg-label"
|
||||
[disabled]="page() <= 1"
|
||||
(click)="setPage(page() - 1)"
|
||||
>
|
||||
Page précédente
|
||||
</button>
|
||||
</li>
|
||||
@for (p of pageNumbers(); track p) {
|
||||
<li>
|
||||
<button
|
||||
class="fr-pagination__link"
|
||||
[class.fr-pagination__link--active]="p === page()"
|
||||
[attr.aria-current]="p === page() ? 'page' : null"
|
||||
(click)="setPage(p)"
|
||||
>
|
||||
{{ p }}
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
<li>
|
||||
<button
|
||||
class="fr-pagination__link fr-pagination__link--next fr-pagination__link--lg-label"
|
||||
[disabled]="page() >= totalPages()"
|
||||
(click)="setPage(page() + 1)"
|
||||
>
|
||||
Page suivante
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class CatalogComponent implements OnInit {
|
||||
private apiService = inject(ApiService);
|
||||
|
||||
apis = signal<ApiEntryListItem[]>([]);
|
||||
loading = signal(false);
|
||||
search = signal('');
|
||||
typeFilter = signal<ApiType | ''>('');
|
||||
page = signal(1);
|
||||
total = signal(0);
|
||||
readonly limit = 12;
|
||||
|
||||
totalPages = computed(() => Math.ceil(this.total() / this.limit));
|
||||
pageNumbers = computed(() => {
|
||||
const pages: number[] = [];
|
||||
for (let i = 1; i <= this.totalPages(); i++) pages.push(i);
|
||||
return pages;
|
||||
});
|
||||
|
||||
private searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor() {
|
||||
/* Rechargement si page change */
|
||||
effect(() => {
|
||||
const _ = this.page();
|
||||
this.loadApis();
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.loadApis();
|
||||
}
|
||||
|
||||
onSearchChange(value: string) {
|
||||
this.search.set(value);
|
||||
if (this.searchTimer) clearTimeout(this.searchTimer);
|
||||
this.searchTimer = setTimeout(() => {
|
||||
this.page.set(1);
|
||||
this.loadApis();
|
||||
}, 400);
|
||||
}
|
||||
|
||||
onTypeChange(value: string) {
|
||||
this.typeFilter.set(value as ApiType | '');
|
||||
this.page.set(1);
|
||||
this.loadApis();
|
||||
}
|
||||
|
||||
setPage(p: number) {
|
||||
if (p < 1 || p > this.totalPages()) return;
|
||||
this.page.set(p);
|
||||
}
|
||||
|
||||
badgeClass(status: string): string {
|
||||
const base = 'fr-badge';
|
||||
if (status === 'GENERATED') return `${base} fr-badge--success`;
|
||||
if (status === 'ERROR') return `${base} fr-badge--error`;
|
||||
return `${base} fr-badge--info`;
|
||||
}
|
||||
|
||||
private loadApis() {
|
||||
this.loading.set(true);
|
||||
this.apiService
|
||||
.list({
|
||||
search: this.search() || undefined,
|
||||
type: (this.typeFilter() as ApiType) || undefined,
|
||||
page: this.page(),
|
||||
limit: this.limit,
|
||||
})
|
||||
.subscribe({
|
||||
next: (res) => {
|
||||
this.apis.set(res.items);
|
||||
this.total.set(res.total);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => this.loading.set(false),
|
||||
});
|
||||
}
|
||||
}
|
||||
0
front-public/src/app/pages/doc-viewer/.gitkeep
Normal file
0
front-public/src/app/pages/doc-viewer/.gitkeep
Normal file
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
OnInit,
|
||||
ChangeDetectionStrategy,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-doc-viewer',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styles: [`
|
||||
.doc-toolbar {
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 1rem;
|
||||
background: var(--background-default-grey);
|
||||
border-bottom: 1px solid var(--border-default-grey);
|
||||
}
|
||||
.doc-iframe {
|
||||
width: 100%;
|
||||
height: calc(100vh - 48px - 73px);
|
||||
border: none;
|
||||
}
|
||||
`],
|
||||
template: `
|
||||
<div class="doc-toolbar">
|
||||
<a
|
||||
[routerLink]="['/catalog', id()]"
|
||||
class="fr-btn fr-btn--tertiary-no-outline fr-btn--icon-left fr-icon-arrow-left-line fr-btn--sm"
|
||||
>
|
||||
Retour
|
||||
</a>
|
||||
</div>
|
||||
@if (docUrl()) {
|
||||
<iframe
|
||||
class="doc-iframe"
|
||||
[src]="docUrl()!"
|
||||
title="Documentation API"
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
></iframe>
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class DocViewerComponent implements OnInit {
|
||||
private route = inject(ActivatedRoute);
|
||||
private sanitizer = inject(DomSanitizer);
|
||||
|
||||
id = signal('');
|
||||
docUrl = signal<SafeResourceUrl | null>(null);
|
||||
|
||||
ngOnInit() {
|
||||
const apiId = this.route.snapshot.paramMap.get('id') ?? '';
|
||||
this.id.set(apiId);
|
||||
this.docUrl.set(
|
||||
this.sanitizer.bypassSecurityTrustResourceUrl(`/api/apis/${apiId}/docs`),
|
||||
);
|
||||
}
|
||||
}
|
||||
0
front-public/src/app/pages/upload/.gitkeep
Normal file
0
front-public/src/app/pages/upload/.gitkeep
Normal file
422
front-public/src/app/pages/upload/upload.component.ts
Normal file
422
front-public/src/app/pages/upload/upload.component.ts
Normal file
@@ -0,0 +1,422 @@
|
||||
import {
|
||||
Component,
|
||||
signal,
|
||||
computed,
|
||||
inject,
|
||||
OnInit,
|
||||
ChangeDetectionStrategy,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { ApiService } from '../../core/api.service';
|
||||
import { CategoryService } from '../../core/category.service';
|
||||
import { Category } from '@datacat/shared';
|
||||
|
||||
@Component({
|
||||
selector: 'app-upload',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule, RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="fr-container fr-mt-4w fr-mb-4w">
|
||||
<!-- Fil d'Ariane -->
|
||||
<nav class="fr-breadcrumb fr-mb-3w" aria-label="vous êtes ici">
|
||||
<ol class="fr-breadcrumb__list">
|
||||
<li><a class="fr-breadcrumb__link" routerLink="/browse">Parcourir</a></li>
|
||||
<li><a class="fr-breadcrumb__link" routerLink="/catalog">Catalogue</a></li>
|
||||
<li><a class="fr-breadcrumb__link" aria-current="page">Importer une API</a></li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<h1 class="fr-h2 fr-mb-4w">Importer une API</h1>
|
||||
|
||||
<!-- Stepper -->
|
||||
<div class="fr-stepper fr-mb-4w">
|
||||
<h2 class="fr-stepper__title">
|
||||
{{ stepTitles[step() - 1] }}
|
||||
<span class="fr-stepper__state">Étape {{ step() }} sur 3</span>
|
||||
</h2>
|
||||
<div
|
||||
class="fr-stepper__steps"
|
||||
[attr.data-fr-current-step]="step()"
|
||||
data-fr-steps="3"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Étape 1 : Fichier(s) YAML -->
|
||||
@if (step() === 1) {
|
||||
<div class="fr-upload-group fr-mb-4w">
|
||||
<label class="fr-label" for="file-input">
|
||||
Fichier(s) YAML
|
||||
<span class="fr-hint-text">
|
||||
Formats acceptés : .yaml, .yml — Sélectionnez plusieurs fichiers si votre spec utilise des $ref
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="file-input"
|
||||
class="fr-upload"
|
||||
type="file"
|
||||
accept=".yaml,.yml"
|
||||
multiple
|
||||
(change)="onFileChange($event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@if (fileError()) {
|
||||
<div class="fr-alert fr-alert--error fr-mb-3w">
|
||||
<p>{{ fileError() }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (selectedFiles().length > 0) {
|
||||
<div class="fr-mb-3w">
|
||||
@for (file of selectedFiles(); track file.name) {
|
||||
<div class="fr-callout fr-callout--blue-cumulus fr-mb-1w" style="display:flex;align-items:center;justify-content:space-between;gap:1rem;">
|
||||
<span>
|
||||
<strong>{{ file.name }}</strong>
|
||||
({{ (file.size / 1024).toFixed(1) }} Ko)
|
||||
</span>
|
||||
@if (file.name === detectedMainFilename()) {
|
||||
<span class="fr-badge fr-badge--success fr-badge--sm">Principal</span>
|
||||
} @else if (selectedFiles().length > 1) {
|
||||
<span class="fr-badge fr-badge--blue-cumulus fr-badge--sm">Secondaire</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="fr-btns-group fr-btns-group--inline-sm">
|
||||
<button
|
||||
class="fr-btn"
|
||||
(click)="goToStep2()"
|
||||
[disabled]="!canProceedFromStep1()"
|
||||
>
|
||||
Suivant
|
||||
</button>
|
||||
<a routerLink="/catalog" class="fr-btn fr-btn--secondary">Annuler</a>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Étape 2 : Métadonnées -->
|
||||
@if (step() === 2) {
|
||||
<form [formGroup]="metaForm" (ngSubmit)="goToStep3()" novalidate>
|
||||
<div class="fr-input-group fr-mb-3w" [class.fr-input-group--error]="titleInvalid()">
|
||||
<label class="fr-label" for="title-input">
|
||||
Titre <span class="fr-hint-text">Obligatoire</span>
|
||||
</label>
|
||||
<input
|
||||
id="title-input"
|
||||
class="fr-input"
|
||||
[class.fr-input--error]="titleInvalid()"
|
||||
formControlName="title"
|
||||
type="text"
|
||||
placeholder="Nom de l'API"
|
||||
/>
|
||||
@if (titleInvalid()) {
|
||||
<p class="fr-error-text">Le titre est obligatoire.</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="fr-input-group fr-mb-3w" [class.fr-input-group--error]="versionInvalid()">
|
||||
<label class="fr-label" for="version-input">
|
||||
Version <span class="fr-hint-text">Obligatoire</span>
|
||||
</label>
|
||||
<input
|
||||
id="version-input"
|
||||
class="fr-input"
|
||||
[class.fr-input--error]="versionInvalid()"
|
||||
formControlName="version"
|
||||
type="text"
|
||||
placeholder="1.0.0"
|
||||
/>
|
||||
@if (versionInvalid()) {
|
||||
<p class="fr-error-text">La version est obligatoire.</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="fr-select-group fr-mb-3w">
|
||||
<label class="fr-label" for="type-input">
|
||||
Type <span class="fr-hint-text">Obligatoire</span>
|
||||
</label>
|
||||
<select id="type-input" class="fr-select" formControlName="type">
|
||||
<option value="ASYNCAPI">AsyncAPI</option>
|
||||
<option value="OPENAPI">OpenAPI</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="fr-select-group fr-mb-3w">
|
||||
<label class="fr-label" for="category-input">
|
||||
Catégorie <span class="fr-hint-text">Optionnel</span>
|
||||
</label>
|
||||
<select id="category-input" class="fr-select" formControlName="categoryId">
|
||||
<option value="">— Sans catégorie —</option>
|
||||
@for (cat of categories(); track cat.id) {
|
||||
<option [value]="cat.id">{{ categoryLabel(cat) }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="fr-input-group fr-mb-4w">
|
||||
<label class="fr-label" for="description-input">
|
||||
Description <span class="fr-hint-text">Optionnel</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="description-input"
|
||||
class="fr-input"
|
||||
formControlName="description"
|
||||
rows="3"
|
||||
placeholder="Description de l'API..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="fr-btns-group fr-btns-group--inline-sm">
|
||||
<button type="submit" class="fr-btn" [disabled]="metaForm.invalid">Suivant</button>
|
||||
<button type="button" class="fr-btn fr-btn--secondary" (click)="step.set(1)">
|
||||
Retour
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
<!-- Étape 3 : Confirmation -->
|
||||
@if (step() === 3) {
|
||||
<div class="fr-card fr-mb-4w">
|
||||
<div class="fr-card__body">
|
||||
<div class="fr-card__content">
|
||||
<h2 class="fr-h5 fr-mb-3w">Récapitulatif</h2>
|
||||
<dl class="fr-grid-row fr-grid-row--gutters">
|
||||
<div class="fr-col-12 fr-col-md-6">
|
||||
<dt class="fr-text--bold">Fichier{{ selectedFiles().length > 1 ? 's' : '' }}</dt>
|
||||
<dd>
|
||||
@if (selectedFiles().length === 1) {
|
||||
{{ selectedFiles()[0].name }}
|
||||
} @else {
|
||||
@for (f of selectedFiles(); track f.name) {
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;">
|
||||
{{ f.name }}
|
||||
@if (f.name === detectedMainFilename()) {
|
||||
<span class="fr-badge fr-badge--success fr-badge--sm">Principal</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="fr-col-12 fr-col-md-6">
|
||||
<dt class="fr-text--bold">Type</dt>
|
||||
<dd>{{ metaForm.value.type }}</dd>
|
||||
</div>
|
||||
<div class="fr-col-12 fr-col-md-6">
|
||||
<dt class="fr-text--bold">Titre</dt>
|
||||
<dd>{{ metaForm.value.title }}</dd>
|
||||
</div>
|
||||
<div class="fr-col-12 fr-col-md-6">
|
||||
<dt class="fr-text--bold">Version</dt>
|
||||
<dd>{{ metaForm.value.version }}</dd>
|
||||
</div>
|
||||
@if (metaForm.value.categoryId) {
|
||||
<div class="fr-col-12 fr-col-md-6">
|
||||
<dt class="fr-text--bold">Catégorie</dt>
|
||||
<dd>{{ selectedCategoryName() }}</dd>
|
||||
</div>
|
||||
}
|
||||
@if (metaForm.value.description) {
|
||||
<div class="fr-col-12">
|
||||
<dt class="fr-text--bold">Description</dt>
|
||||
<dd>{{ metaForm.value.description }}</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (uploadError()) {
|
||||
<div class="fr-alert fr-alert--error fr-mb-3w">
|
||||
<p class="fr-alert__title">Erreur lors de l'import</p>
|
||||
<p>{{ uploadError() }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="fr-btns-group fr-btns-group--inline-sm">
|
||||
<button
|
||||
class="fr-btn"
|
||||
(click)="onSubmit()"
|
||||
[disabled]="uploading()"
|
||||
>
|
||||
{{ uploading() ? 'Import en cours...' : 'Importer' }}
|
||||
</button>
|
||||
<button class="fr-btn fr-btn--secondary" (click)="step.set(2)" [disabled]="uploading()">
|
||||
Retour
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class UploadComponent implements OnInit {
|
||||
private fb = inject(FormBuilder);
|
||||
private apiService = inject(ApiService);
|
||||
private categoryService = inject(CategoryService);
|
||||
private router = inject(Router);
|
||||
|
||||
step = signal(1);
|
||||
selectedFiles = signal<File[]>([]);
|
||||
detectedType = signal<'ASYNCAPI' | 'OPENAPI' | null>(null);
|
||||
detectedMainFilename = signal<string | null>(null);
|
||||
fileError = signal<string | null>(null);
|
||||
uploading = signal(false);
|
||||
uploadError = signal<string | null>(null);
|
||||
categories = signal<Category[]>([]);
|
||||
|
||||
readonly stepTitles = [
|
||||
'Choisir le fichier',
|
||||
'Renseigner les métadonnées',
|
||||
"Confirmer l'import",
|
||||
];
|
||||
|
||||
metaForm = this.fb.group({
|
||||
title: ['', Validators.required],
|
||||
version: ['', Validators.required],
|
||||
type: ['ASYNCAPI', Validators.required],
|
||||
categoryId: [''],
|
||||
description: [''],
|
||||
});
|
||||
|
||||
titleInvalid = computed(
|
||||
() => !!(this.metaForm.get('title')?.invalid && this.metaForm.get('title')?.touched),
|
||||
);
|
||||
versionInvalid = computed(
|
||||
() => !!(this.metaForm.get('version')?.invalid && this.metaForm.get('version')?.touched),
|
||||
);
|
||||
selectedCategoryName = computed(() => {
|
||||
const id = this.metaForm.value.categoryId;
|
||||
if (!id) return '';
|
||||
return this.categories().find((c) => c.id === id)?.name ?? '';
|
||||
});
|
||||
canProceedFromStep1 = computed(() => {
|
||||
if (this.selectedFiles().length === 0) return false;
|
||||
if (this.selectedFiles().length === 1) return true;
|
||||
/* Plusieurs fichiers : un fichier principal doit être détecté */
|
||||
return this.detectedMainFilename() !== null;
|
||||
});
|
||||
|
||||
ngOnInit() {
|
||||
this.categoryService.list().subscribe({
|
||||
next: (res) => this.categories.set(res.items),
|
||||
});
|
||||
}
|
||||
|
||||
categoryLabel(cat: Category): string {
|
||||
/* Indente visuellement les sous-catégories via le parentId */
|
||||
const hasParent = !!cat.parentId;
|
||||
return hasParent ? ` └ ${cat.name}` : cat.name;
|
||||
}
|
||||
|
||||
onFileChange(event: Event) {
|
||||
this.fileError.set(null);
|
||||
this.detectedType.set(null);
|
||||
this.detectedMainFilename.set(null);
|
||||
|
||||
const input = event.target as HTMLInputElement;
|
||||
const fileList = input.files;
|
||||
if (!fileList || fileList.length === 0) {
|
||||
this.selectedFiles.set([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const files = Array.from(fileList);
|
||||
const invalid = files.find((f) => !f.name.match(/\.(yaml|yml)$/i));
|
||||
if (invalid) {
|
||||
this.fileError.set('Seuls les fichiers .yaml et .yml sont acceptés.');
|
||||
this.selectedFiles.set([]);
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectedFiles.set(files);
|
||||
|
||||
/* Détection côté client du fichier principal (pré-remplissage du type) */
|
||||
void this.detectMainFileClient(files);
|
||||
}
|
||||
|
||||
goToStep2() {
|
||||
if (!this.canProceedFromStep1()) return;
|
||||
this.step.set(2);
|
||||
}
|
||||
|
||||
goToStep3() {
|
||||
if (this.metaForm.invalid) {
|
||||
this.metaForm.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
this.step.set(3);
|
||||
}
|
||||
|
||||
onSubmit() {
|
||||
const files = this.selectedFiles();
|
||||
if (files.length === 0 || this.metaForm.invalid) return;
|
||||
|
||||
this.uploading.set(true);
|
||||
this.uploadError.set(null);
|
||||
|
||||
const formData = new FormData();
|
||||
for (const f of files) {
|
||||
formData.append('files', f);
|
||||
}
|
||||
formData.append('title', this.metaForm.value.title ?? '');
|
||||
formData.append('version', this.metaForm.value.version ?? '');
|
||||
formData.append('type', this.metaForm.value.type ?? 'ASYNCAPI');
|
||||
if (this.metaForm.value.categoryId) {
|
||||
formData.append('categoryId', this.metaForm.value.categoryId);
|
||||
}
|
||||
if (this.metaForm.value.description) {
|
||||
formData.append('description', this.metaForm.value.description);
|
||||
}
|
||||
|
||||
this.apiService.upload(formData).subscribe({
|
||||
next: (entry) => {
|
||||
this.uploading.set(false);
|
||||
this.router.navigate(['/catalog', entry.id]);
|
||||
},
|
||||
error: (err) => {
|
||||
this.uploading.set(false);
|
||||
this.uploadError.set(err.error?.message ?? "Erreur lors de l'import");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async detectMainFileClient(files: File[]): Promise<void> {
|
||||
for (const file of files) {
|
||||
const content = await this.readFileAsText(file);
|
||||
if (/^openapi\s*:/m.test(content)) {
|
||||
this.detectedType.set('OPENAPI');
|
||||
this.detectedMainFilename.set(file.name);
|
||||
this.metaForm.patchValue({ type: 'OPENAPI' });
|
||||
return;
|
||||
}
|
||||
if (/^asyncapi\s*:/m.test(content)) {
|
||||
this.detectedType.set('ASYNCAPI');
|
||||
this.detectedMainFilename.set(file.name);
|
||||
this.metaForm.patchValue({ type: 'ASYNCAPI' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
/* Aucun fichier principal détecté parmi plusieurs fichiers */
|
||||
if (files.length > 1) {
|
||||
this.fileError.set(
|
||||
'Aucun fichier principal détecté (clé openapi: ou asyncapi: requise dans au moins un fichier).',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private readFileAsText(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => resolve((e.target?.result as string) ?? '');
|
||||
reader.onerror = () => reject(new Error('Lecture fichier échouée'));
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}
|
||||
}
|
||||
0
front-public/src/app/shared/.gitkeep
Normal file
0
front-public/src/app/shared/.gitkeep
Normal file
19
front-public/src/index.html
Normal file
19
front-public/src/index.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Datacat</title>
|
||||
<base href="/" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||
<!-- DSFR -->
|
||||
<link rel="stylesheet" href="assets/dsfr/dsfr.min.css" />
|
||||
<link rel="stylesheet" href="assets/dsfr/utility/icons/icons.min.css" />
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
<!-- DSFR JS -->
|
||||
<script type="module" src="assets/dsfr/dsfr.module.min.js"></script>
|
||||
<script type="text/javascript" nomodule src="assets/dsfr/dsfr.nomodule.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
7
front-public/src/main.ts
Normal file
7
front-public/src/main.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { AppComponent } from './app/app.component';
|
||||
import { appConfig } from './app/app.config';
|
||||
|
||||
bootstrapApplication(AppComponent, appConfig).catch((err) =>
|
||||
console.error(err),
|
||||
);
|
||||
8
front-public/src/styles.scss
Normal file
8
front-public/src/styles.scss
Normal file
@@ -0,0 +1,8 @@
|
||||
// DSFR is loaded via assets (see index.html)
|
||||
// This file is for global app styles only
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
25
front-public/tsconfig.json
Normal file
25
front-public/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"lib": ["ES2022", "dom"],
|
||||
"outDir": "./dist/out-tsc",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"paths": {
|
||||
"@datacat/shared": ["../shared/src/index.ts"]
|
||||
}
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user