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:
@@ -40,16 +40,6 @@ import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
|
||||
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>
|
||||
|
||||
@@ -18,8 +18,8 @@ export const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'catalog',
|
||||
loadComponent: () =>
|
||||
import('./pages/catalog/catalog.component').then((m) => m.CatalogComponent),
|
||||
redirectTo: 'browse',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'catalog/:id',
|
||||
|
||||
@@ -12,7 +12,8 @@ import { CommonModule } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
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({
|
||||
selector: 'app-browse',
|
||||
@@ -52,6 +53,14 @@ import { CategoryBrowseResponse, Category, CategoryWithCounts } from '@datacat/s
|
||||
</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()) {
|
||||
<div class="fr-callout fr-mb-4w">
|
||||
<p class="fr-callout__text">Chargement...</p>
|
||||
@@ -64,6 +73,7 @@ import { CategoryBrowseResponse, Category, CategoryWithCounts } from '@datacat/s
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!isSearchMode()) {
|
||||
@if (!loading() && browseData()) {
|
||||
<!-- Sous-catégories -->
|
||||
@if (browseData()!.subcategories.length > 0) {
|
||||
@@ -137,6 +147,48 @@ import { CategoryBrowseResponse, Category, CategoryWithCounts } from '@datacat/s
|
||||
</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) -->
|
||||
@if (showFormModal()) {
|
||||
@@ -192,6 +244,7 @@ import { CategoryBrowseResponse, Category, CategoryWithCounts } from '@datacat/s
|
||||
export class BrowseComponent implements OnInit {
|
||||
private route = inject(ActivatedRoute);
|
||||
private categoryService = inject(CategoryService);
|
||||
private apiService = inject(ApiService);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
browseData = signal<CategoryBrowseResponse | null>(null);
|
||||
@@ -199,6 +252,19 @@ export class BrowseComponent implements OnInit {
|
||||
error = 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 */
|
||||
showFormModal = signal(false);
|
||||
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 {
|
||||
return type === 'ASYNCAPI'
|
||||
? 'fr-badge fr-badge--sm fr-badge--purple-glycine'
|
||||
|
||||
Reference in New Issue
Block a user