diff --git a/angular.json b/angular.json index 870e874..d4c7f50 100644 --- a/angular.json +++ b/angular.json @@ -112,6 +112,7 @@ } }, "cli": { - "schematicCollections": ["@angular-eslint/schematics"] + "schematicCollections": ["@angular-eslint/schematics"], + "analytics": false } } diff --git a/src/app/about/about.component.html b/src/app/about/about.component.html new file mode 100644 index 0000000..f1e5482 --- /dev/null +++ b/src/app/about/about.component.html @@ -0,0 +1,4 @@ +
+

This app is all about your books.

+ This link demonstrates the redirect of '/' to '/about' +
diff --git a/src/app/about/about.component.scss b/src/app/about/about.component.scss new file mode 100644 index 0000000..1b65a34 --- /dev/null +++ b/src/app/about/about.component.scss @@ -0,0 +1,12 @@ +:host { + height: 50vh; + display: grid; + align-items: center; + justify-content: center; + + color: #064d9e; +} + +.link-blue { + color: #064d9e; +} diff --git a/src/app/about/about.component.ts b/src/app/about/about.component.ts new file mode 100644 index 0000000..900b2a7 --- /dev/null +++ b/src/app/about/about.component.ts @@ -0,0 +1,11 @@ +import { Component } from '@angular/core'; +import { RouterLink } from '@angular/router'; + +@Component({ + selector: 'app-about', + standalone: true, + imports: [RouterLink], + templateUrl: './about.component.html', + styleUrl: './about.component.scss' +}) +export class AboutComponent {} diff --git a/src/app/app.component.html b/src/app/app.component.html index bd081d1..4052606 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1,328 +1,3 @@ - - - - - - - - + - - -
-
-
- -

Hello, {{ title }}

-

Congratulations! Your app is running. 🎉

-
- -
-
- @for (item of [ { title: 'Explore the Docs', link: 'https://angular.dev' - }, { title: 'Learn with Tutorials', link: - 'https://angular.dev/tutorials' }, { title: 'CLI Docs', link: - 'https://angular.dev/tools/cli' }, { title: 'Angular Language Service', - link: 'https://angular.dev/tools/language-service' }, { title: 'Angular - DevTools', link: 'https://angular.dev/tools/devtools' }, ]; track - item.title) { - - {{ item.title }} - - - - - } -
- -
-
-
- - - - - - - - + diff --git a/src/app/app.component.ts b/src/app/app.component.ts index f7f27e5..69bb429 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,13 +1,12 @@ import { Component } from '@angular/core'; -import { CommonModule } from '@angular/common'; +import { RouterOutlet } from '@angular/router'; +import { NavigationComponent } from './navigation/navigation.component'; @Component({ selector: 'app-root', standalone: true, - imports: [CommonModule], + imports: [NavigationComponent, RouterOutlet], templateUrl: './app.component.html', styleUrl: './app.component.scss' }) -export class AppComponent { - title = 'bookmonkey-client'; -} +export class AppComponent {} diff --git a/src/app/app.config.ts b/src/app/app.config.ts index c4a32cc..f3884a4 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -1,5 +1,11 @@ import { ApplicationConfig } from '@angular/core'; +import { provideHttpClient } from '@angular/common/http'; +import { provideRouter, withComponentInputBinding } from '@angular/router'; +import { routes } from './app.routes'; export const appConfig: ApplicationConfig = { - providers: [] + providers: [ + provideHttpClient(), + provideRouter(routes, withComponentInputBinding()) + ] }; diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts new file mode 100644 index 0000000..4e976ee --- /dev/null +++ b/src/app/app.routes.ts @@ -0,0 +1,21 @@ +import { Routes } from '@angular/router'; +import { AboutComponent } from './about/about.component'; +import { isUserAuthenticatedGuardFn } from './is-user-authenticated.guard'; + +export const routes: Routes = [ + { + path: '', + redirectTo: '/about', + pathMatch: 'full' + }, + { + path: 'about', + component: AboutComponent + }, + { + path: 'books', + loadChildren: () => + import('./book/book.routes').then(mod => mod.bookRoutes), + canMatch: [isUserAuthenticatedGuardFn] + } +]; diff --git a/src/app/book/book-api.service.ts b/src/app/book/book-api.service.ts new file mode 100644 index 0000000..58a0cd3 --- /dev/null +++ b/src/app/book/book-api.service.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@angular/core'; +import { Book } from './book'; +import { Observable } from 'rxjs'; +import { HttpClient } from '@angular/common/http'; + +@Injectable({ + providedIn: 'root' +}) +export class BookApiService { + readonly #baseUrl = 'http://localhost:4730'; + + constructor(private readonly http: HttpClient) { + } + + getAll(): Observable { + return this.http.get(`${this.#baseUrl}/books`); + } + + getByIsbn(isbn: string): Observable { + return this.http.get(`${this.#baseUrl}/books/${isbn}`); + } + + create(book: Partial): Observable { + return this.http.post('http://localhost:4730/books', book); + } +} diff --git a/src/app/book/book-card/book-card.component.html b/src/app/book/book-card/book-card.component.html new file mode 100644 index 0000000..5a19ec4 --- /dev/null +++ b/src/app/book/book-card/book-card.component.html @@ -0,0 +1,4 @@ +

{{ content.title }}

+

{{ content.author }}

+Details +

{{ content.abstract }}

diff --git a/src/app/book/book-card/book-card.component.scss b/src/app/book/book-card/book-card.component.scss new file mode 100644 index 0000000..503889e --- /dev/null +++ b/src/app/book/book-card/book-card.component.scss @@ -0,0 +1,14 @@ +:host { + display: inline-block; + border-radius: 2px; + margin: 1rem; + padding: 1rem; + width: 300px; + + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24); + transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1); + + &:hover { + box-shadow: 0 14px 28px rgba(0, 0, 0, 0.25), 0 10px 10px rgba(0, 0, 0, 0.22); + } +} diff --git a/src/app/book/book-card/book-card.component.ts b/src/app/book/book-card/book-card.component.ts new file mode 100644 index 0000000..552738c --- /dev/null +++ b/src/app/book/book-card/book-card.component.ts @@ -0,0 +1,24 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { Book } from '../book'; + +@Component({ + selector: 'app-book-card', + standalone: true, + imports: [], + templateUrl: './book-card.component.html', + styleUrl: './book-card.component.scss' +}) +export class BookCardComponent { + customStyle = { color: '#064D9E', fontWeight: 600 }; + + @Input({ required: true }) content!: Book; + @Output() detailClick = new EventEmitter(); + + handleDetailClick(click: MouseEvent) { + click.preventDefault(); + + console.log('Click Details-Link:', click); + + this.detailClick.emit(this.content); + } +} diff --git a/src/app/book/book-detail/book-detail.component.html b/src/app/book/book-detail/book-detail.component.html new file mode 100644 index 0000000..c58b956 --- /dev/null +++ b/src/app/book/book-detail/book-detail.component.html @@ -0,0 +1,13 @@ + +

+ {{ book.title }} +

+

ISBN - {{ book.isbn }}

+
+

+ {{ book.abstract }} + {{ book.author }} +

+ +
+
diff --git a/src/app/book/book-detail/book-detail.component.scss b/src/app/book/book-detail/book-detail.component.scss new file mode 100644 index 0000000..cd7ea72 --- /dev/null +++ b/src/app/book/book-detail/book-detail.component.scss @@ -0,0 +1,14 @@ +:host { + display: block; + margin: 1rem; +} + +.book-insights { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 2rem; +} + +.book-cover { + max-height: 200px; +} diff --git a/src/app/book/book-detail/book-detail.component.ts b/src/app/book/book-detail/book-detail.component.ts new file mode 100644 index 0000000..04dee82 --- /dev/null +++ b/src/app/book/book-detail/book-detail.component.ts @@ -0,0 +1,23 @@ +import { Component, Input } from '@angular/core'; +import { Observable } from 'rxjs'; +import { Book } from '../book'; +import { BookApiService } from '../book-api.service'; +import { AsyncPipe, NgIf } from '@angular/common'; + +@Component({ + selector: 'app-book-detail', + standalone: true, + imports: [AsyncPipe, NgIf], + templateUrl: './book-detail.component.html', + styleUrl: './book-detail.component.scss' +}) +export class BookDetailComponent { + book$!: Observable; + + constructor(private readonly bookApi: BookApiService) {} + + @Input({ required: true }) + set isbn(isbn: string) { + this.book$ = this.bookApi.getByIsbn(isbn); + } +} diff --git a/src/app/book/book-filter/book-filter.pipe.ts b/src/app/book/book-filter/book-filter.pipe.ts new file mode 100644 index 0000000..0714071 --- /dev/null +++ b/src/app/book/book-filter/book-filter.pipe.ts @@ -0,0 +1,58 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import { Book } from '../book'; + +@Pipe({ + name: 'bookFilter', + standalone: true +}) +export class BookFilterPipe implements PipeTransform { + /** + * A note on defensive design + * -------------------------- + * + * We type books as Book[] or null here. + * This is done because we do not know in which context "| bookFilter" is used. + * For example "bookFilter" could sit in a chain of pipes that produce invalid values. + * + * That's why we want to make sure to handle possible null values explicitly. + * + * @param books A collection of books + * @param searchTerm The search term to filter books + */ + transform( + books: Book[] | null | undefined, + searchTerm: string | null + ): Book[] { + if (!searchTerm) { + return books || []; + } + + if (!books) { + return []; + } + + return books.filter(book => + book.title.toLocaleLowerCase().includes(searchTerm.toLocaleLowerCase()) + ); + + /* + * Bonus + * ----- + * If you want to search in all fields the book provides you can iterate + * through its properties. The code would look like this. + * + * return books.filter( + * book => this.matchBook(book, searchTerm) + * ); + * + * ... + * + * private matchBook(book: { [key: string]: any }, searchTerm: string): boolean { + * return Object.keys(book) + * .filter(key => typeof book[key] === 'string') + * .some(key => book[key].toLocaleLowerCase().includes(searchTerm.toLocaleLowerCase()) + * ); + * } + */ + } +} diff --git a/src/app/book/book-new/book-new.component.html b/src/app/book/book-new/book-new.component.html new file mode 100644 index 0000000..f816f64 --- /dev/null +++ b/src/app/book/book-new/book-new.component.html @@ -0,0 +1,41 @@ +

New

+ +
+ + + + + + + + + + +
+ +
+
diff --git a/src/app/book/book-new/book-new.component.scss b/src/app/book/book-new/book-new.component.scss new file mode 100644 index 0000000..1c74532 --- /dev/null +++ b/src/app/book/book-new/book-new.component.scss @@ -0,0 +1,31 @@ +:host { + display: block; + margin: 1rem; +} + +.form-field { + display: block; + margin-top: 1rem; + + > span { + display: block; + margin-bottom: 0.5rem; + } + + > input { + padding: 12px 20px 12px 6px; + border: 1px solid #ccc; + border-radius: 4px; + box-sizing: border-box; + min-width: 250px; + } +} + +.form-field-hint { + display: block; + color: #3c3c3c; +} + +.form-actions { + margin-top: 1rem; +} diff --git a/src/app/book/book-new/book-new.component.ts b/src/app/book/book-new/book-new.component.ts new file mode 100644 index 0000000..ce4985c --- /dev/null +++ b/src/app/book/book-new/book-new.component.ts @@ -0,0 +1,30 @@ +import { Component } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { NgIf } from '@angular/common'; +import { BookApiService } from '../book-api.service'; +import { take } from 'rxjs'; +import { validAuthorName } from '../validators/author.validator'; + +@Component({ + selector: 'app-book-new', + standalone: true, + imports: [ReactiveFormsModule, NgIf], + templateUrl: './book-new.component.html', + styleUrls: ['./book-new.component.scss'] +}) +export class BookNewComponent { + form = this.formBuilder.nonNullable.group({ + title: ['', [Validators.required]], + subtitle: [''], + author: ['', [Validators.required, validAuthorName()]], + abstract: [''], + isbn: [''] + }); + + constructor(private readonly formBuilder: FormBuilder, private readonly bookApiService: BookApiService) { + } + + submit() { + this.bookApiService.create(this.form.getRawValue()).pipe(take(1)).subscribe() + } +} diff --git a/src/app/book/book.component.html b/src/app/book/book.component.html new file mode 100644 index 0000000..d50088b --- /dev/null +++ b/src/app/book/book.component.html @@ -0,0 +1,17 @@ + + + + + + diff --git a/src/app/book/book.component.scss b/src/app/book/book.component.scss new file mode 100644 index 0000000..71d4fa2 --- /dev/null +++ b/src/app/book/book.component.scss @@ -0,0 +1,8 @@ +.book-filter { + display: block; + margin: 1rem; + padding: 12px 20px; + border: 1px solid #ccc; + border-radius: 4px; + box-sizing: border-box; +} diff --git a/src/app/book/book.component.ts b/src/app/book/book.component.ts new file mode 100644 index 0000000..56adfc7 --- /dev/null +++ b/src/app/book/book.component.ts @@ -0,0 +1,35 @@ +import { Component, Signal } from '@angular/core'; +import { BookCardComponent } from './book-card/book-card.component'; +import { BookFilterPipe } from './book-filter/book-filter.pipe'; +import { Book } from './book'; +import { CommonModule } from '@angular/common'; +import { BookApiService } from './book-api.service'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { Router, RouterLink, RouterLinkActive } from '@angular/router'; + +@Component({ + selector: 'app-book', + standalone: true, + imports: [CommonModule, BookCardComponent, BookFilterPipe, RouterLink, RouterLinkActive], + templateUrl: './book.component.html', + styleUrl: './book.component.scss' +}) +export class BookComponent { + bookSearchTerm = ''; + books: Signal; + + constructor( + private readonly bookApi: BookApiService, + private readonly router: Router + ) { + this.books = toSignal(this.bookApi.getAll()); + } + + goToBookDetails(book: Book) { + this.router.navigate(['books', 'detail', book.isbn]); + } + + updateBookSearchTerm(input: Event) { + this.bookSearchTerm = (input.target as HTMLInputElement).value; + } +} diff --git a/src/app/book/book.routes.ts b/src/app/book/book.routes.ts new file mode 100644 index 0000000..d8bf688 --- /dev/null +++ b/src/app/book/book.routes.ts @@ -0,0 +1,21 @@ +import { Routes } from '@angular/router'; +import { BookComponent } from './book.component'; +import { BookDetailComponent } from './book-detail/book-detail.component'; +import { confirmLeaveGuardFn } from './confirm-leave.guard'; +import { BookNewComponent } from './book-new/book-new.component'; + +export const bookRoutes: Routes = [ + { + path: '', + component: BookComponent + }, + { + path: 'new', + component: BookNewComponent + }, + { + path: 'detail/:isbn', + component: BookDetailComponent, + canDeactivate: [confirmLeaveGuardFn] + } +]; diff --git a/src/app/book/book.ts b/src/app/book/book.ts new file mode 100644 index 0000000..d17fac5 --- /dev/null +++ b/src/app/book/book.ts @@ -0,0 +1,7 @@ +export interface Book { + isbn: string; + cover: string; + title: string; + abstract: string; + author: string; +} diff --git a/src/app/book/confirm-leave.guard.ts b/src/app/book/confirm-leave.guard.ts new file mode 100644 index 0000000..4f6f8fd --- /dev/null +++ b/src/app/book/confirm-leave.guard.ts @@ -0,0 +1,11 @@ +import { CanDeactivateFn } from '@angular/router'; +import { BookDetailComponent } from './book-detail/book-detail.component'; + +export const confirmLeaveGuardFn: CanDeactivateFn = ( + route, + state +) => { + const wantsToLeave = confirm('Do you really want to leave?'); + + return wantsToLeave; +}; diff --git a/src/app/book/validators/author.validator.ts b/src/app/book/validators/author.validator.ts new file mode 100644 index 0000000..24cdffb --- /dev/null +++ b/src/app/book/validators/author.validator.ts @@ -0,0 +1,13 @@ +import {AbstractControl, ValidationErrors, ValidatorFn} from '@angular/forms'; + +export function validAuthorName(): ValidatorFn { + return (control:AbstractControl) : ValidationErrors | null => { + const value = control.value; + if (!value) { + return null; + } + + const hasNumeric = /[0-9]+/.test(value); + return hasNumeric ? { invalidAuthor : true }: null; + } +} diff --git a/src/app/is-user-authenticated.guard.ts b/src/app/is-user-authenticated.guard.ts new file mode 100644 index 0000000..20d4097 --- /dev/null +++ b/src/app/is-user-authenticated.guard.ts @@ -0,0 +1,9 @@ +import { CanMatchFn } from '@angular/router'; +import { UserStateService } from './user-state.service'; +import { inject } from '@angular/core'; + +export const isUserAuthenticatedGuardFn: CanMatchFn = (route, state) => { + const service = inject(UserStateService); + + return service.isLoggedIn; +}; diff --git a/src/app/navigation/navigation.component.html b/src/app/navigation/navigation.component.html new file mode 100644 index 0000000..90c6bf6 --- /dev/null +++ b/src/app/navigation/navigation.component.html @@ -0,0 +1,4 @@ + diff --git a/src/app/navigation/navigation.component.scss b/src/app/navigation/navigation.component.scss new file mode 100644 index 0000000..483d5c5 --- /dev/null +++ b/src/app/navigation/navigation.component.scss @@ -0,0 +1,23 @@ +ul { + list-style-type: none; + margin: 0; + padding: 0; + overflow: hidden; + background-color: #333; + li { + float: left; + } + a { + display: block; + color: white; + text-align: center; + padding: 14px 16px; + text-decoration: none; + &:hover:not(.active) { + background-color: #111; + } + &.active { + background-color: #4caf50; + } + } +} diff --git a/src/app/navigation/navigation.component.ts b/src/app/navigation/navigation.component.ts new file mode 100644 index 0000000..2f369da --- /dev/null +++ b/src/app/navigation/navigation.component.ts @@ -0,0 +1,11 @@ +import { Component } from '@angular/core'; +import { RouterLink, RouterLinkActive } from '@angular/router'; + +@Component({ + selector: 'app-navigation', + standalone: true, + imports: [RouterLink, RouterLinkActive], + templateUrl: './navigation.component.html', + styleUrl: './navigation.component.scss' +}) +export class NavigationComponent {} diff --git a/src/app/user-state.service.ts b/src/app/user-state.service.ts new file mode 100644 index 0000000..e2e1d4c --- /dev/null +++ b/src/app/user-state.service.ts @@ -0,0 +1,6 @@ +import { Injectable } from '@angular/core'; + +@Injectable({ providedIn: 'root' }) +export class UserStateService { + isLoggedIn = true; +}