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,6 +73,7 @@ import { CategoryBrowseResponse, Category, CategoryWithCounts } from '@datacat/s
</div> </div>
} }
@if (!isSearchMode()) {
@if (!loading() && browseData()) { @if (!loading() && browseData()) {
<!-- Sous-catégories --> <!-- Sous-catégories -->
@if (browseData()!.subcategories.length > 0) { @if (browseData()!.subcategories.length > 0) {
@@ -137,6 +147,48 @@ import { CategoryBrowseResponse, Category, CategoryWithCounts } from '@datacat/s
</div> </div>
} }
} }
} @else {
<!-- Mode recherche -->
@if (searchLoading()) {
<div class="fr-callout fr-mb-4w">
<p class="fr-callout__text">Chargement...</p>
</div>
}
@if (!searchLoading() && searchResults().length === 0) {
<p class="fr-text--lead">Aucune API trouvée pour « {{ searchQuery() }} »</p>
}
<div class="fr-grid-row fr-grid-row--gutters">
@for (api of searchResults(); track api.id) {
<div class="fr-col-12 fr-col-md-6 fr-col-lg-4">
<div class="fr-card fr-card--sm 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>
@if (api.description) {
<p class="fr-card__desc">{{ api.description }}</p>
}
<div class="fr-badges-group fr-mt-1w">
<span [class]="typeBadgeClass(api.type)">{{ api.type }}</span>
<span [class]="statusBadgeClass(api.status)">{{ api.status }}</span>
</div>
</div>
</div>
</div>
</div>
}
</div>
@if (searchTotalPages() > 1) {
<div class="fr-grid-row fr-grid-row--center fr-mt-4w">
<button class="fr-btn fr-btn--secondary fr-btn--sm" [disabled]="searchPage() === 1"
(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>
}
}
<!-- Modale création / édition (overlay Angular, sans fr-modal pour éviter l'interférence du JS DSFR) --> <!-- Modale création / édition (overlay Angular, sans fr-modal pour éviter l'interférence du JS DSFR) -->
@if (showFormModal()) { @if (showFormModal()) {
@@ -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'