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
|
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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user