feat(browse): fusion Parcourir + Catalogue en une seule page

- Barre de recherche intégrée dans BrowseComponent
- Mode navigation (tuiles) si recherche vide, mode liste (cartes) si texte
- Debounce 400ms + pagination pour les résultats de recherche
- Suppression de l'entrée "Catalogue" du menu de navigation
- Redirection /catalog → /browse (vieux liens toujours fonctionnels)

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:
z3n
2026-06-09 09:41:10 +00:00
parent a0677df5ec
commit a82c52c491
3 changed files with 159 additions and 72 deletions

View File

@@ -40,16 +40,6 @@ import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
Parcourir Parcourir
</a> </a>
</li> </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> </ul>
</nav> </nav>
</div> </div>

View File

@@ -18,8 +18,8 @@ export const routes: Routes = [
}, },
{ {
path: 'catalog', path: 'catalog',
loadComponent: () => redirectTo: 'browse',
import('./pages/catalog/catalog.component').then((m) => m.CatalogComponent), pathMatch: 'full',
}, },
{ {
path: 'catalog/:id', path: 'catalog/:id',

View File

@@ -12,7 +12,8 @@ import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { CategoryService } from '../../core/category.service'; import { CategoryService } from '../../core/category.service';
import { CategoryBrowseResponse, Category, CategoryWithCounts } from '@datacat/shared'; import { ApiService } from '../../core/api.service';
import { CategoryBrowseResponse, Category, CategoryWithCounts, ApiEntryListItem } from '@datacat/shared';
@Component({ @Component({
selector: 'app-browse', selector: 'app-browse',
@@ -52,6 +53,14 @@ import { CategoryBrowseResponse, Category, CategoryWithCounts } from '@datacat/s
</div> </div>
</div> </div>
<!-- Barre de recherche -->
<div class="fr-search-bar fr-mb-3w">
<label class="fr-label" for="search-api">Rechercher une API</label>
<input id="search-api" class="fr-input" type="search" placeholder="Titre, description..."
[value]="searchQuery()"
(input)="onSearchChange($any($event.target).value)" />
</div>
@if (loading()) { @if (loading()) {
<div class="fr-callout fr-mb-4w"> <div class="fr-callout fr-mb-4w">
<p class="fr-callout__text">Chargement...</p> <p class="fr-callout__text">Chargement...</p>
@@ -64,76 +73,119 @@ import { CategoryBrowseResponse, Category, CategoryWithCounts } from '@datacat/s
</div> </div>
} }
@if (!loading() && browseData()) { @if (!isSearchMode()) {
<!-- Sous-catégories --> @if (!loading() && browseData()) {
@if (browseData()!.subcategories.length > 0) { <!-- Sous-catégories -->
<h2 class="fr-h4 fr-mb-3w">Catégories</h2> @if (browseData()!.subcategories.length > 0) {
<div class="fr-grid-row fr-grid-row--gutters fr-mb-4w"> <h2 class="fr-h4 fr-mb-3w">Catégories</h2>
@for (cat of browseData()!.subcategories; track cat.id) { <div class="fr-grid-row fr-grid-row--gutters fr-mb-4w">
<div class="fr-col-12 fr-col-md-4"> @for (cat of browseData()!.subcategories; track cat.id) {
<div class="fr-tile fr-enlarge-link fr-tile--sm"> <div class="fr-col-12 fr-col-md-4">
<div class="fr-tile__body"> <div class="fr-tile fr-enlarge-link fr-tile--sm">
<div class="fr-tile__content"> <div class="fr-tile__body">
<h3 class="fr-tile__title"> <div class="fr-tile__content">
<a [routerLink]="['/browse', cat.id]">{{ cat.name }}</a> <h3 class="fr-tile__title">
</h3> <a [routerLink]="['/browse', cat.id]">{{ cat.name }}</a>
@if (cat.description) { </h3>
<p class="fr-tile__desc">{{ cat.description }}</p> @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 style="position:relative;z-index:1;margin-top:0.5rem;display:flex;gap:0.25rem;order:5">
<button class="fr-btn fr-btn--tertiary-no-outline fr-btn--sm fr-btn--icon-left fr-icon-edit-line"
(click)="openEdit(cat); $event.stopPropagation()">
Modifier
</button>
@if (cat.apiCount === 0 && cat.subcategoryCount === 0) {
<button class="fr-btn fr-btn--tertiary-no-outline fr-btn--sm fr-btn--icon-left fr-icon-delete-bin-line"
(click)="deleteCategory(cat); $event.stopPropagation()">
Supprimer
</button>
} }
<p class="fr-tile__detail">
{{ cat.subcategoryCount }} sous-catégorie(s) · {{ cat.apiCount }} API(s)
</p>
<div style="position:relative;z-index:1;margin-top:0.5rem;display:flex;gap:0.25rem;order:5">
<button class="fr-btn fr-btn--tertiary-no-outline fr-btn--sm fr-btn--icon-left fr-icon-edit-line"
(click)="openEdit(cat); $event.stopPropagation()">
Modifier
</button>
@if (cat.apiCount === 0 && cat.subcategoryCount === 0) {
<button class="fr-btn fr-btn--tertiary-no-outline fr-btn--sm fr-btn--icon-left fr-icon-delete-bin-line"
(click)="deleteCategory(cat); $event.stopPropagation()">
Supprimer
</button>
}
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> }
} </div>
}
<!-- APIs -->
@if (browseData()!.apis.length > 0) {
<h2 class="fr-h4 fr-mb-3w">APIs</h2>
<div class="fr-grid-row fr-grid-row--gutters">
@for (api of browseData()!.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>
&nbsp;
<span [class]="statusBadgeClass(api.status)">{{ api.status }}</span>
</p>
</div>
</div>
</div>
</div>
}
</div>
}
@if (browseData()!.subcategories.length === 0 && browseData()!.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>
}
}
} @else {
<!-- Mode recherche -->
@if (searchLoading()) {
<div class="fr-callout fr-mb-4w">
<p class="fr-callout__text">Chargement...</p>
</div> </div>
} }
@if (!searchLoading() && searchResults().length === 0) {
<!-- APIs --> <p class="fr-text--lead">Aucune API trouvée pour « {{ searchQuery() }} »</p>
@if (browseData()!.apis.length > 0) { }
<h2 class="fr-h4 fr-mb-3w">APIs</h2> <div class="fr-grid-row fr-grid-row--gutters">
<div class="fr-grid-row fr-grid-row--gutters"> @for (api of searchResults(); track api.id) {
@for (api of browseData()!.apis; track api.id) { <div class="fr-col-12 fr-col-md-6 fr-col-lg-4">
<div class="fr-col-12 fr-col-md-4"> <div class="fr-card fr-card--sm fr-enlarge-link">
<div class="fr-tile fr-enlarge-link fr-tile--sm"> <div class="fr-card__body">
<div class="fr-tile__body"> <div class="fr-card__content">
<div class="fr-tile__content"> <h3 class="fr-card__title">
<h3 class="fr-tile__title"> <a [routerLink]="['/catalog', api.id]">{{ api.title }}</a>
<a [routerLink]="['/catalog', api.id]">{{ api.title }}</a> </h3>
</h3> @if (api.description) {
@if (api.description) { <p class="fr-card__desc">{{ api.description }}</p>
<p class="fr-tile__desc">{{ api.description }}</p> }
} <div class="fr-badges-group fr-mt-1w">
<p class="fr-tile__detail"> <span [class]="typeBadgeClass(api.type)">{{ api.type }}</span>
<span [class]="typeBadgeClass(api.type)">{{ api.type }}</span> <span [class]="statusBadgeClass(api.status)">{{ api.status }}</span>
&nbsp;
<span [class]="statusBadgeClass(api.status)">{{ api.status }}</span>
</p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
} </div>
</div> }
} </div>
@if (searchTotalPages() > 1) {
@if (browseData()!.subcategories.length === 0 && browseData()!.apis.length === 0) { <div class="fr-grid-row fr-grid-row--center fr-mt-4w">
<div class="fr-callout fr-callout--blue-cumulus fr-mb-4w"> <button class="fr-btn fr-btn--secondary fr-btn--sm" [disabled]="searchPage() === 1"
<p class="fr-callout__text">Aucun contenu dans cette catégorie.</p> (click)="setSearchPage(searchPage() - 1)">Précédent</button>
<span class="fr-mx-2w">Page {{ searchPage() }} / {{ searchTotalPages() }}</span>
<button class="fr-btn fr-btn--secondary fr-btn--sm" [disabled]="searchPage() === searchTotalPages()"
(click)="setSearchPage(searchPage() + 1)">Suivant</button>
</div> </div>
} }
} }
@@ -192,6 +244,7 @@ import { CategoryBrowseResponse, Category, CategoryWithCounts } from '@datacat/s
export class BrowseComponent implements OnInit { export class BrowseComponent implements OnInit {
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private categoryService = inject(CategoryService); private categoryService = inject(CategoryService);
private apiService = inject(ApiService);
private destroyRef = inject(DestroyRef); private destroyRef = inject(DestroyRef);
browseData = signal<CategoryBrowseResponse | null>(null); browseData = signal<CategoryBrowseResponse | null>(null);
@@ -199,6 +252,19 @@ export class BrowseComponent implements OnInit {
error = signal<string | null>(null); error = signal<string | null>(null);
categoryId = signal<string | null>(null); categoryId = signal<string | null>(null);
/* Signaux recherche */
searchQuery = signal('');
searchResults = signal<ApiEntryListItem[]>([]);
searchTotal = signal(0);
searchPage = signal(1);
searchLoading = signal(false);
readonly searchLimit = 12;
isSearchMode = computed(() => this.searchQuery().trim().length > 0);
searchTotalPages = computed(() => Math.ceil(this.searchTotal() / this.searchLimit));
private searchDebounce: ReturnType<typeof setTimeout> | null = null;
/* Signaux pour la modale */ /* Signaux pour la modale */
showFormModal = signal(false); showFormModal = signal(false);
formMode = signal<'create' | 'edit'>('create'); formMode = signal<'create' | 'edit'>('create');
@@ -302,6 +368,37 @@ export class BrowseComponent implements OnInit {
}); });
} }
onSearchChange(value: string) {
this.searchQuery.set(value);
this.searchPage.set(1);
if (this.searchDebounce) clearTimeout(this.searchDebounce);
if (value.trim()) {
this.searchDebounce = setTimeout(() => this.loadSearch(), 400);
}
}
loadSearch() {
this.searchLoading.set(true);
this.apiService.list({
search: this.searchQuery(),
page: this.searchPage(),
limit: this.searchLimit,
}).subscribe({
next: (res) => {
this.searchResults.set(res.items);
this.searchTotal.set(res.total);
this.searchLoading.set(false);
},
error: () => this.searchLoading.set(false),
});
}
setSearchPage(p: number) {
if (p < 1 || p > this.searchTotalPages()) return;
this.searchPage.set(p);
this.loadSearch();
}
typeBadgeClass(type: string): string { typeBadgeClass(type: string): string {
return type === 'ASYNCAPI' return type === 'ASYNCAPI'
? 'fr-badge fr-badge--sm fr-badge--purple-glycine' ? 'fr-badge fr-badge--sm fr-badge--purple-glycine'