Skip to content

Commit d4c64b9

Browse files
committed
chore: layout improvements
Co-Authored-by: Bertrand Zuchuat <bertrand.zuchuat@rero.ch>
1 parent c456ca2 commit d4c64b9

29 files changed

+579
-238
lines changed

angular.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,13 @@
4444
"maximumError": "8kB"
4545
}
4646
],
47-
"outputHashing": "all"
47+
"outputHashing": "all",
48+
"fileReplacements": [
49+
{
50+
"replace": "src/environments/environment.ts",
51+
"with": "src/environments/environment.prod.ts"
52+
}
53+
]
4854
},
4955
"development": {
5056
"optimization": false,

public/images/logo.svg

Lines changed: 10 additions & 0 deletions
Loading

src/app/app.config.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { provideHttpClient, withInterceptors } from '@angular/common/http';
22
import { ApplicationConfig, inject, provideBrowserGlobalErrorListeners, provideEnvironmentInitializer, provideZonelessChangeDetection } from '@angular/core';
33
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
44
import { provideRouter, TitleStrategy } from '@angular/router';
5-
import Lara from '@primeuix/themes/lara';
5+
import Aura from '@primeuix/themes/Aura';
66
import { MessageService } from 'primeng/api';
77
import { providePrimeNG } from 'primeng/config';
88
import { routes } from './app.routes';
@@ -27,8 +27,8 @@ export const appConfig: ApplicationConfig = {
2727
providePrimeNG({
2828
ripple: true,
2929
theme: {
30-
preset: Lara
31-
}
30+
preset: Aura
31+
}
3232
}),
3333
{ provide: TitleStrategy, useClass: PageTitleStrategy },
3434
{ provide: OpenLibraryBase, useClass: OpenLibraryApi },

src/app/app.routes.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export const routes: Routes = [
1111
path: 'login',
1212
pathMatch: 'full',
1313
loadComponent: () => import('./features/login/login'),
14+
title: 'Login'
1415
},
1516
{
1617
path: 'todo',
@@ -21,7 +22,7 @@ export const routes: Routes = [
2122
{
2223
path: 'books',
2324
loadComponent: () => import('./features/books/books'),
24-
title: 'Livres'
25+
title: 'Bibliothèque'
2526
},
2627
{
2728
path: '**',

src/app/app.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
1-
import { Component } from '@angular/core';
1+
import { Component, inject } from '@angular/core';
22
import { RouterOutlet } from '@angular/router';
33
import { Toast } from 'primeng/toast';
4-
import { Menu } from './features/menu/menu';
4+
import { Sidebar } from './features/sidebar/sidebar';
55
import { AppStateStore } from './shared/appSate/app-state-store';
6+
import { LayoutService } from './shared/layout/layout.service';
67

78
@Component({
89
selector: 'app-root',
9-
imports: [RouterOutlet, Menu, Toast],
10+
imports: [RouterOutlet, Sidebar, Toast],
1011
providers: [AppStateStore],
1112
template: `
12-
<div class="m-4">
13-
<app-menu />
14-
<div class="mt-4 mx-4 flex justify-center">
15-
<div class="w-[80%]">
13+
<div class="min-h-screen bg-gray-50">
14+
<app-sidebar />
15+
<div class="transition-all duration-300 p-4"
16+
[class.ml-64]="layoutService.sidebarExpanded()"
17+
[class.ml-16]="!layoutService.sidebarExpanded()">
18+
<div class="max-w-7xl mx-auto">
1619
<router-outlet />
1720
</div>
1821
<p-toast [showTransitionOptions]="'250ms'" [showTransformOptions]="'translateX(100%)'" [hideTransitionOptions]="'150ms'" [hideTransformOptions]="'translateX(100%)'" />
@@ -21,4 +24,5 @@ import { AppStateStore } from './shared/appSate/app-state-store';
2124
`
2225
})
2326
export class App {
27+
protected readonly layoutService = inject(LayoutService);
2428
}

src/app/features/books/book.ts

Lines changed: 100 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,112 @@
1-
import { Component, input } from '@angular/core';
1+
import { Component, computed, input } from '@angular/core';
2+
import { environment } from '@env/environment';
23
import { OpenLibraryRecord } from './model/open-library.model';
34

45
@Component({
56
selector: 'app-book',
67
imports: [],
78
template: `
8-
<div class="flex flex-col">
9-
<h1>{{ book().title }}</h1>
10-
@if (book().author_name) {
11-
<div><span class="font-bold">Auteurs:</span> {{ book().author_name.join(', ') }}</div>
12-
}
13-
@if (book().language) {
14-
<div><span class="font-bold">Langues:</span> {{ book().language.join(', ') }}</div>
15-
}
9+
<div class="bg-white rounded-xl shadow-md border border-gray-100 overflow-hidden hover:shadow-xl hover:border-[#1EA1CF]/30 transition-all duration-300 group h-full flex flex-col">
10+
<!-- Book Cover -->
11+
<div class="relative bg-gradient-to-br from-gray-100 to-gray-200 h-64 flex items-center justify-center overflow-hidden">
12+
@if (coverUrl()) {
13+
<img
14+
[src]="coverUrl()"
15+
[alt]="book().title"
16+
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
17+
(error)="onImageError($event)" />
18+
} @else {
19+
<div class="text-center p-6">
20+
<i class="pi pi-book text-6xl text-gray-400 mb-3"></i>
21+
<p class="text-sm text-gray-500">Couverture non disponible</p>
22+
</div>
23+
}
24+
<!-- Overlay badge for publication year -->
25+
@if (book().first_publish_year) {
26+
<div class="absolute top-3 right-3 bg-[#1765A2] text-white px-3 py-1 rounded-full text-sm font-medium shadow-lg">
27+
{{ book().first_publish_year }}
28+
</div>
29+
}
30+
</div>
31+
32+
<!-- Book Info -->
33+
<div class="p-5 flex-1 flex flex-col">
34+
<!-- Title -->
35+
<h3 class="text-lg font-bold text-gray-800 mb-2 line-clamp-2 group-hover:text-[#1765A2] transition-colors">
36+
{{ book().title }}
37+
</h3>
38+
39+
<!-- Authors -->
40+
@if (book().author_name && book().author_name.length > 0) {
41+
<div class="flex items-start gap-2 mb-3">
42+
<i class="pi pi-user text-[#1EA1CF] text-sm mt-1 flex-shrink-0"></i>
43+
<p class="text-sm text-gray-600 line-clamp-2">
44+
{{ book().author_name.join(', ') }}
45+
</p>
46+
</div>
47+
}
48+
49+
<!-- Metadata -->
50+
<div class="mt-auto space-y-2">
51+
<!-- Languages -->
52+
@if (book().language && book().language.length > 0) {
53+
<div class="flex items-center gap-2">
54+
<i class="pi pi-globe text-gray-400 text-xs"></i>
55+
<span class="text-xs text-gray-500">
56+
{{ book().language.slice(0, 3).join(', ') }}
57+
@if (book().language.length > 3) {
58+
<span class="text-gray-400">+{{ book().language.length - 3 }}</span>
59+
}
60+
</span>
61+
</div>
62+
}
63+
64+
<!-- Publisher -->
65+
@if (book().publisher?.length) {
66+
<div class="flex items-center gap-2">
67+
<i class="pi pi-building text-gray-400 text-xs"></i>
68+
<span class="text-xs text-gray-500 truncate">
69+
{{ book().publisher?.[0] }}
70+
</span>
71+
</div>
72+
}
73+
74+
<!-- Edition count -->
75+
@if (book().edition_count && book().edition_count! > 1) {
76+
<div class="flex items-center gap-2">
77+
<i class="pi pi-copy text-gray-400 text-xs"></i>
78+
<span class="text-xs text-gray-500">
79+
{{ book().edition_count }} édition{{ book().edition_count! > 1 ? 's' : '' }}
80+
</span>
81+
</div>
82+
}
83+
</div>
84+
</div>
1685
</div>
1786
`
1887
})
1988
export class Book {
2089
book = input.required<OpenLibraryRecord>();
90+
91+
// Compute cover URL from ISBN or cover_i
92+
coverUrl = computed(() => {
93+
const bookData = this.book();
94+
95+
// Try to get cover from cover_i (cover edition id)
96+
if (bookData.cover_i) {
97+
return `${environment.openLibraryCoverApiUrl}/b/id/${bookData.cover_i}-L.jpg`;
98+
}
99+
100+
// Try to get cover from ISBN
101+
if (bookData.isbn && bookData.isbn.length > 0) {
102+
return `${environment.openLibraryCoverApiUrl}/b/isbn/${bookData.isbn[0]}-L.jpg`;
103+
}
104+
105+
return null;
106+
});
107+
108+
onImageError(event: Event) {
109+
// Hide broken image
110+
(event.target as HTMLImageElement).style.display = 'none';
111+
}
21112
}

src/app/features/books/books.html

Lines changed: 43 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,50 @@
1-
<div class="flex flex-col gap-4">
2-
<h1>Livres</h1>
1+
<div class="max-w-7xl mx-auto p-6">
2+
<!-- Header -->
3+
<div class="mb-8">
4+
<h1 class="text-3xl font-bold text-gray-800 mb-2">Bibliothèque</h1>
5+
<p class="text-gray-500">Recherchez et découvrez des livres dans la collection Open Library</p>
6+
</div>
37

4-
<app-search />
8+
<!-- Search Section -->
9+
<div class="mb-6">
10+
<app-search />
11+
</div>
512

13+
<!-- Results Section -->
614
@if(store.total() > 0) {
7-
<div class="font-sm italic">Nombre de résultats: {{ store.total() }}</div>
8-
<div class="mt-4">
9-
@for(document of store.documents(); track document.key; let last = $last) {
10-
<app-book [book]="document" />
11-
@if (!last) {
12-
<p-divider />
13-
}
14-
}
15-
</div>
16-
<div class="mt-4">
17-
<shared-paginator [paginatorState]="paginatorConfig()" (pageChange)="store.setPaginator($event)" />
15+
<!-- Results Header -->
16+
<div class="mb-6 flex items-center justify-between">
17+
<div class="flex items-center gap-2 text-sm text-gray-600">
18+
<i class="pi pi-book"></i>
19+
<span class="font-medium">{{ store.total() }} résultat{{ store.total() > 1 ? 's' : '' }} trouvé{{ store.total() >
20+
1 ? 's' : '' }}</span>
1821
</div>
22+
</div>
23+
24+
<!-- Books Grid -->
25+
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
26+
@for(document of store.documents(); track document.key) {
27+
<app-book [book]="document" />
28+
}
29+
</div>
30+
31+
<!-- Pagination -->
32+
<div class="flex justify-center">
33+
<shared-paginator [paginatorState]="paginatorConfig()" (pageChange)="store.setPaginator($event)" />
34+
</div>
1935
} @else {
20-
<div class="font-sm italic">Aucun résultat…</div>
36+
<!-- Empty State -->
37+
<div class="text-center py-16">
38+
<div
39+
class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-gradient-to-br from-[#1765A2]/10 to-[#1EA1CF]/10 mb-6">
40+
<i class="pi pi-search text-4xl text-[#1765A2]"></i>
41+
</div>
42+
<h3 class="text-xl font-semibold text-gray-700 mb-2">Aucun résultat trouvé</h3>
43+
<p class="text-gray-500 mb-6">Essayez une autre recherche ou modifiez vos critères</p>
44+
<div class="text-sm text-gray-400">
45+
<i class="pi pi-info-circle mr-1"></i>
46+
Utilisez la barre de recherche ci-dessus pour explorer la bibliothèque
47+
</div>
48+
</div>
2149
}
2250
</div>

src/app/features/books/books.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
import { Component, inject, Signal } from '@angular/core';
22
import { deepComputed } from '@ngrx/signals';
3-
import { Divider } from 'primeng/divider';
43
import { PaginatorComponent, PaginatorConfig } from '../../shared/component/paginator';
54
import { Book } from './book';
65
import { Search } from './search';
76
import { OpenLibraryStore } from './store/open-library.store';
87

98
@Component({
109
selector: 'app-books',
11-
imports: [Search, Book, Divider, PaginatorComponent],
10+
imports: [Search, Book, PaginatorComponent],
1211
templateUrl: './books.html',
1312
providers: [OpenLibraryStore]
1413
})

src/app/features/books/model/open-library.model.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ export type OpenLibraryRecord = {
1111
language: string[];
1212
key: string;
1313
title: string;
14+
cover_i?: number;
15+
isbn?: string[];
16+
first_publish_year?: number;
17+
publisher?: string[];
18+
edition_count?: number;
1419
}
1520

1621
export const availableFields = [
@@ -19,4 +24,9 @@ export const availableFields = [
1924
'language',
2025
'key',
2126
'title',
27+
'cover_i',
28+
'isbn',
29+
'first_publish_year',
30+
'publisher',
31+
'edition_count',
2232
];

src/app/features/books/search.ts

Lines changed: 34 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,44 @@
11
import { Component, inject } from '@angular/core';
2-
import { Button } from 'primeng/button';
3-
import { InputGroup } from 'primeng/inputgroup';
4-
import { InputGroupAddon } from 'primeng/inputgroupaddon';
52
import { InputText } from "primeng/inputtext";
63
import { OpenLibraryStore } from './store/open-library.store';
4+
import { Button } from 'primeng/button';
75

86
@Component({
97
selector: 'app-search',
10-
imports: [InputText, InputGroup, InputGroupAddon, Button],
8+
imports: [InputText, Button],
119
template: `
12-
<p-inputgroup>
13-
<input
14-
fluid="true"
15-
#input
16-
type="text"
17-
pInputText
18-
placeholder="Rechercher…"
19-
[value]="store.filter()"
20-
(keyup.enter)="store.search(input.value)"
21-
/>
22-
<p-inputgroup-addon>
23-
<p-button icon="pi pi-times" severity="secondary" (onClick)="store.reset()" />
24-
</p-inputgroup-addon>
25-
<p-inputgroup-addon>
26-
<i
27-
class="pi"
28-
[class]="store.isPending() ? 'pi-spin pi-spinner text-blue-500' : 'pi-search'"
29-
></i>
30-
</p-inputgroup-addon>
31-
</p-inputgroup>
10+
<div class="bg-white rounded-xl shadow-md border border-gray-100 p-6">
11+
<div class="flex gap-3">
12+
<div class="flex-1 relative">
13+
<input
14+
#input
15+
type="text"
16+
pInputText
17+
placeholder="Rechercher un livre, un auteur..."
18+
[value]="store.filter()"
19+
(keyup.enter)="store.search(input.value)"
20+
class="w-full transition-all"
21+
/>
22+
</div>
23+
24+
@if (store.filter()) {
25+
<p-button severity="secondary" (click)="store.reset()">
26+
<i class="pi pi-times"></i>
27+
<span class="hidden sm:inline">Effacer</span>
28+
</p-button>
29+
}
30+
31+
<p-button severity="info" [disabled]="store.isPending()" (click)="store.search(input.value)">
32+
@if (store.isPending()) {
33+
<i class="pi pi-spin pi-spinner"></i>
34+
<span class="hidden sm:inline">Recherche...</span>
35+
} @else {
36+
<i class="pi pi-search"></i>
37+
<span class="hidden sm:inline">Rechercher</span>
38+
}
39+
</p-button>
40+
</div>
41+
</div>
3242
`,
3343
})
3444
export class Search {

0 commit comments

Comments
 (0)