@@ -5,17 +5,7 @@
|
|
5 |
"projects": {
|
6 |
"book-monkey": {
|
7 |
"projectType": "application",
|
8 |
-
"schematics": {
|
9 |
-
"@schematics/angular:component": {
|
10 |
-
"standalone": false
|
11 |
-
},
|
12 |
-
"@schematics/angular:directive": {
|
13 |
-
"standalone": false
|
14 |
-
},
|
15 |
-
"@schematics/angular:pipe": {
|
16 |
-
"standalone": false
|
17 |
-
}
|
18 |
-
},
|
19 |
"root": "",
|
20 |
"sourceRoot": "src",
|
21 |
"prefix": "bm",
|
@@ -39,7 +29,15 @@
|
|
39 |
"styles": [
|
40 |
"src/styles.css"
|
41 |
],
|
42 |
-
"scripts": []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
43 |
},
|
44 |
"configurations": {
|
45 |
"production": {
|
5 |
"projects": {
|
6 |
"book-monkey": {
|
7 |
"projectType": "application",
|
8 |
+
"schematics": {},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
9 |
"root": "",
|
10 |
"sourceRoot": "src",
|
11 |
"prefix": "bm",
|
29 |
"styles": [
|
30 |
"src/styles.css"
|
31 |
],
|
32 |
+
"scripts": [],
|
33 |
+
"server": "src/main.server.ts",
|
34 |
+
"prerender": {
|
35 |
+
"discoverRoutes": true,
|
36 |
+
"routesFile": "routes.txt"
|
37 |
+
},
|
38 |
+
"ssr": {
|
39 |
+
"entry": "server.ts"
|
40 |
+
}
|
41 |
},
|
42 |
"configurations": {
|
43 |
"production": {
|
@@ -7,7 +7,8 @@
|
|
7 |
"build": "ng build",
|
8 |
"watch": "ng build --watch --configuration development",
|
9 |
"test": "ng test",
|
10 |
-
"lint": "ng lint"
|
|
|
11 |
},
|
12 |
"private": true,
|
13 |
"dependencies": {
|
@@ -19,9 +20,12 @@
|
|
19 |
"@angular/forms": "^18.2.0",
|
20 |
"@angular/platform-browser": "^18.2.0",
|
21 |
"@angular/platform-browser-dynamic": "^18.2.0",
|
|
|
22 |
"@angular/router": "^18.2.0",
|
|
|
23 |
"angular-date-value-accessor": "^3.0.0",
|
24 |
"book-monkey5-styles": "^1.0.4",
|
|
|
25 |
"rxjs": "~7.8.0",
|
26 |
"tslib": "^2.3.0",
|
27 |
"zone.js": "~0.14.10"
|
@@ -30,7 +34,9 @@
|
|
30 |
"@angular-devkit/build-angular": "^18.2.1",
|
31 |
"@angular/cli": "^18.2.1",
|
32 |
"@angular/compiler-cli": "^18.2.0",
|
|
|
33 |
"@types/jasmine": "~5.1.0",
|
|
|
34 |
"angular-eslint": "18.3.0",
|
35 |
"eslint": "^9.9.0",
|
36 |
"jasmine-core": "~5.2.0",
|
@@ -42,4 +48,4 @@
|
|
42 |
"typescript": "~5.5.2",
|
43 |
"typescript-eslint": "8.1.0"
|
44 |
}
|
45 |
-
}
|
7 |
"build": "ng build",
|
8 |
"watch": "ng build --watch --configuration development",
|
9 |
"test": "ng test",
|
10 |
+
"lint": "ng lint",
|
11 |
+
"serve:ssr:book-monkey": "node dist/book-monkey/server/server.mjs"
|
12 |
},
|
13 |
"private": true,
|
14 |
"dependencies": {
|
20 |
"@angular/forms": "^18.2.0",
|
21 |
"@angular/platform-browser": "^18.2.0",
|
22 |
"@angular/platform-browser-dynamic": "^18.2.0",
|
23 |
+
"@angular/platform-server": "^18.2.0",
|
24 |
"@angular/router": "^18.2.0",
|
25 |
+
"@angular/ssr": "^18.2.1",
|
26 |
"angular-date-value-accessor": "^3.0.0",
|
27 |
"book-monkey5-styles": "^1.0.4",
|
28 |
+
"express": "^4.18.2",
|
29 |
"rxjs": "~7.8.0",
|
30 |
"tslib": "^2.3.0",
|
31 |
"zone.js": "~0.14.10"
|
34 |
"@angular-devkit/build-angular": "^18.2.1",
|
35 |
"@angular/cli": "^18.2.1",
|
36 |
"@angular/compiler-cli": "^18.2.0",
|
37 |
+
"@types/express": "^4.17.17",
|
38 |
"@types/jasmine": "~5.1.0",
|
39 |
+
"@types/node": "^18.18.0",
|
40 |
"angular-eslint": "18.3.0",
|
41 |
"eslint": "^9.9.0",
|
42 |
"jasmine-core": "~5.2.0",
|
48 |
"typescript": "~5.5.2",
|
49 |
"typescript-eslint": "8.1.0"
|
50 |
}
|
51 |
+
}
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/books/9783864909467
|
2 |
+
/books/9783864903571
|
3 |
+
/books/9783864906466
|
4 |
+
/books/9783864907791
|
5 |
+
/books/9783864904523
|
6 |
+
/books/9783960091417
|
7 |
+
/books/9783864907845
|
8 |
+
/books/9783864905520
|
9 |
+
/books/9783864902079
|
10 |
+
/books/9783864909009
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { APP_BASE_HREF } from '@angular/common';
|
2 |
+
import { CommonEngine } from '@angular/ssr';
|
3 |
+
import express from 'express';
|
4 |
+
import { fileURLToPath } from 'node:url';
|
5 |
+
import { dirname, join, resolve } from 'node:path';
|
6 |
+
import AppServerModule from './src/main.server';
|
7 |
+
|
8 |
+
// The Express app is exported so that it can be used by serverless Functions.
|
9 |
+
export function app(): express.Express {
|
10 |
+
const server = express();
|
11 |
+
const serverDistFolder = dirname(fileURLToPath(import.meta.url));
|
12 |
+
const browserDistFolder = resolve(serverDistFolder, '../browser');
|
13 |
+
const indexHtml = join(serverDistFolder, 'index.server.html');
|
14 |
+
|
15 |
+
const commonEngine = new CommonEngine();
|
16 |
+
|
17 |
+
server.set('view engine', 'html');
|
18 |
+
server.set('views', browserDistFolder);
|
19 |
+
|
20 |
+
// Example Express Rest API endpoints
|
21 |
+
// server.get('/api/**', (req, res) => { });
|
22 |
+
// Serve static files from /browser
|
23 |
+
server.get('**', express.static(browserDistFolder, {
|
24 |
+
maxAge: '1y',
|
25 |
+
index: 'index.html',
|
26 |
+
}));
|
27 |
+
|
28 |
+
// All regular routes use the Angular engine
|
29 |
+
server.get('**', (req, res, next) => {
|
30 |
+
const { protocol, originalUrl, baseUrl, headers } = req;
|
31 |
+
|
32 |
+
commonEngine
|
33 |
+
.render({
|
34 |
+
bootstrap: AppServerModule,
|
35 |
+
documentFilePath: indexHtml,
|
36 |
+
url: `${protocol}://${headers.host}${originalUrl}`,
|
37 |
+
publicPath: browserDistFolder,
|
38 |
+
providers: [{ provide: APP_BASE_HREF, useValue: baseUrl }],
|
39 |
+
})
|
40 |
+
.then((html) => res.send(html))
|
41 |
+
.catch((err) => next(err));
|
42 |
+
});
|
43 |
+
|
44 |
+
return server;
|
45 |
+
}
|
46 |
+
|
47 |
+
function run(): void {
|
48 |
+
const port = process.env['PORT'] || 4000;
|
49 |
+
|
50 |
+
// Start up the Node server
|
51 |
+
const server = app();
|
52 |
+
server.listen(port, () => {
|
53 |
+
console.log(`Node Express server listening on http://localhost:${port}`);
|
54 |
+
});
|
55 |
+
}
|
56 |
+
|
57 |
+
run();
|
@@ -1,26 +0,0 @@
|
|
1 |
-
import { NgModule } from '@angular/core';
|
2 |
-
import { CommonModule } from '@angular/common';
|
3 |
-
import { ReactiveFormsModule } from '@angular/forms';
|
4 |
-
import { LocalIsoDateValueAccessor } from 'angular-date-value-accessor';
|
5 |
-
|
6 |
-
import { AdminRoutingModule } from './admin-routing.module';
|
7 |
-
import { BookFormComponent } from './book-form/book-form.component';
|
8 |
-
import { BookCreateComponent } from './book-create/book-create.component';
|
9 |
-
import { BookEditComponent } from './book-edit/book-edit.component';
|
10 |
-
import { FormErrorsComponent } from './form-errors/form-errors.component';
|
11 |
-
|
12 |
-
@NgModule({
|
13 |
-
declarations: [
|
14 |
-
BookFormComponent,
|
15 |
-
BookCreateComponent,
|
16 |
-
BookEditComponent,
|
17 |
-
FormErrorsComponent
|
18 |
-
],
|
19 |
-
imports: [
|
20 |
-
CommonModule,
|
21 |
-
AdminRoutingModule,
|
22 |
-
ReactiveFormsModule,
|
23 |
-
LocalIsoDateValueAccessor
|
24 |
-
],
|
25 |
-
})
|
26 |
-
export class AdminModule { }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,9 +1,8 @@
|
|
1 |
-
import {
|
2 |
-
import { RouterModule, Routes } from '@angular/router';
|
3 |
import { BookCreateComponent } from './book-create/book-create.component';
|
4 |
import { BookEditComponent } from './book-edit/book-edit.component';
|
5 |
|
6 |
-
const
|
7 |
{
|
8 |
path: '',
|
9 |
redirectTo: 'create',
|
@@ -18,9 +17,3 @@ const routes: Routes = [
|
|
18 |
component: BookEditComponent,
|
19 |
}
|
20 |
];
|
21 |
-
|
22 |
-
@NgModule({
|
23 |
-
imports: [RouterModule.forChild(routes)],
|
24 |
-
exports: [RouterModule]
|
25 |
-
})
|
26 |
-
export class AdminRoutingModule { }
|
1 |
+
import { Routes } from '@angular/router';
|
|
|
2 |
import { BookCreateComponent } from './book-create/book-create.component';
|
3 |
import { BookEditComponent } from './book-edit/book-edit.component';
|
4 |
|
5 |
+
export const ADMIN_ROUTES: Routes = [
|
6 |
{
|
7 |
path: '',
|
8 |
redirectTo: 'create',
|
17 |
component: BookEditComponent,
|
18 |
}
|
19 |
];
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,20 +1,20 @@
|
|
1 |
-
import { Component } from '@angular/core';
|
2 |
import { Router } from '@angular/router';
|
3 |
|
4 |
import { BookStoreService } from '../../shared/book-store.service';
|
5 |
import { Book } from '../../shared/book';
|
|
|
6 |
|
7 |
@Component({
|
8 |
selector: 'bm-book-create',
|
9 |
templateUrl: './book-create.component.html',
|
10 |
-
|
|
|
|
|
11 |
})
|
12 |
export class BookCreateComponent {
|
13 |
-
|
14 |
-
|
15 |
-
private service: BookStoreService,
|
16 |
-
private router: Router
|
17 |
-
) { }
|
18 |
|
19 |
create(book: Book) {
|
20 |
this.service.create(book).subscribe(createdBook => {
|
1 |
+
import { Component, inject } from '@angular/core';
|
2 |
import { Router } from '@angular/router';
|
3 |
|
4 |
import { BookStoreService } from '../../shared/book-store.service';
|
5 |
import { Book } from '../../shared/book';
|
6 |
+
import { BookFormComponent } from '../book-form/book-form.component';
|
7 |
|
8 |
@Component({
|
9 |
selector: 'bm-book-create',
|
10 |
templateUrl: './book-create.component.html',
|
11 |
+
styleUrl: './book-create.component.css',
|
12 |
+
imports: [BookFormComponent],
|
13 |
+
standalone: true
|
14 |
})
|
15 |
export class BookCreateComponent {
|
16 |
+
private service = inject(BookStoreService);
|
17 |
+
private router = inject(Router);
|
|
|
|
|
|
|
18 |
|
19 |
create(book: Book) {
|
20 |
this.service.create(book).subscribe(createdBook => {
|
@@ -1,6 +1,7 @@
|
|
1 |
<h1>Edit Book</h1>
|
2 |
|
|
|
3 |
<bm-book-form
|
4 |
-
*ngIf="book$ | async as book"
|
5 |
[book]="book"
|
6 |
(submitBook)="update($event)"></bm-book-form>
|
|
1 |
<h1>Edit Book</h1>
|
2 |
|
3 |
+
@if (book$ | async; as book) {
|
4 |
<bm-book-form
|
|
|
5 |
[book]="book"
|
6 |
(submitBook)="update($event)"></bm-book-form>
|
7 |
+
}
|
@@ -1,28 +1,27 @@
|
|
1 |
-
import {
|
2 |
-
import {
|
3 |
-
import {
|
|
|
|
|
4 |
|
5 |
import { Book } from '../../shared/book';
|
6 |
import { BookStoreService } from '../../shared/book-store.service';
|
|
|
7 |
|
8 |
@Component({
|
9 |
selector: 'bm-book-edit',
|
10 |
templateUrl: './book-edit.component.html',
|
11 |
-
|
|
|
|
|
12 |
})
|
13 |
export class BookEditComponent {
|
14 |
-
|
15 |
-
|
16 |
-
|
17 |
-
|
18 |
-
|
19 |
-
|
20 |
-
) {
|
21 |
-
this.book$ = this.route.paramMap.pipe(
|
22 |
-
map(params => params.get('isbn')!),
|
23 |
-
switchMap(isbn => this.service.getSingle(isbn))
|
24 |
-
);
|
25 |
-
}
|
26 |
|
27 |
update(book: Book) {
|
28 |
this.service.update(book).subscribe(updatedBook => {
|
1 |
+
import { AsyncPipe } from '@angular/common';
|
2 |
+
import { Component, inject, input } from '@angular/core';
|
3 |
+
import { toObservable } from '@angular/core/rxjs-interop';
|
4 |
+
import { Router } from '@angular/router';
|
5 |
+
import { switchMap } from 'rxjs';
|
6 |
|
7 |
import { Book } from '../../shared/book';
|
8 |
import { BookStoreService } from '../../shared/book-store.service';
|
9 |
+
import { BookFormComponent } from '../book-form/book-form.component';
|
10 |
|
11 |
@Component({
|
12 |
selector: 'bm-book-edit',
|
13 |
templateUrl: './book-edit.component.html',
|
14 |
+
styleUrl: './book-edit.component.css',
|
15 |
+
imports: [AsyncPipe, BookFormComponent],
|
16 |
+
standalone: true
|
17 |
})
|
18 |
export class BookEditComponent {
|
19 |
+
private service = inject(BookStoreService);
|
20 |
+
private router = inject(Router);
|
21 |
+
isbn = input.required<string>();
|
22 |
+
book$ = toObservable(this.isbn).pipe(
|
23 |
+
switchMap(isbn => this.service.getSingle(isbn))
|
24 |
+
);
|
|
|
|
|
|
|
|
|
|
|
|
|
25 |
|
26 |
update(book: Book) {
|
27 |
this.service.update(book).subscribe(updatedBook => {
|
@@ -26,10 +26,11 @@
|
|
26 |
+ Author
|
27 |
</button>
|
28 |
<fieldset formArrayName="authors">
|
|
|
29 |
<input
|
30 |
-
|
31 |
-
[
|
32 |
-
|
33 |
</fieldset>
|
34 |
<bm-form-errors
|
35 |
controlName="authors"
|
26 |
+ Author
|
27 |
</button>
|
28 |
<fieldset formArrayName="authors">
|
29 |
+
@for (a of authors.controls; track a) {
|
30 |
<input
|
31 |
+
[attr.aria-label]="'Author ' + $index"
|
32 |
+
[formControlName]="$index">
|
33 |
+
}
|
34 |
</fieldset>
|
35 |
<bm-form-errors
|
36 |
controlName="authors"
|
@@ -1,18 +1,20 @@
|
|
1 |
-
import { Component,
|
2 |
-
import { FormArray, FormControl, FormGroup, Validators } from '@angular/forms';
|
3 |
|
4 |
import { Book } from '../../shared/book';
|
5 |
-
import {
|
6 |
-
import { atLeastOneValue, isbnFormat } from '../shared/validators';
|
7 |
|
8 |
@Component({
|
9 |
selector: 'bm-book-form',
|
10 |
templateUrl: './book-form.component.html',
|
11 |
-
|
|
|
|
|
12 |
})
|
13 |
-
export class BookFormComponent
|
14 |
-
|
15 |
-
|
16 |
|
17 |
form = new FormGroup({
|
18 |
title: new FormControl('', {
|
@@ -22,11 +24,8 @@ export class BookFormComponent implements OnChanges {
|
|
22 |
subtitle: new FormControl('', { nonNullable: true }),
|
23 |
isbn: new FormControl('', {
|
24 |
nonNullable: true,
|
25 |
-
validators: [
|
26 |
-
|
27 |
-
isbnFormat
|
28 |
-
],
|
29 |
-
asyncValidators: inject(AsyncValidatorsService).isbnExists()
|
30 |
}),
|
31 |
description: new FormControl('', { nonNullable: true }),
|
32 |
published: new FormControl('', { nonNullable: true }),
|
@@ -34,13 +33,16 @@ export class BookFormComponent implements OnChanges {
|
|
34 |
thumbnailUrl: new FormControl('', { nonNullable: true })
|
35 |
});
|
36 |
|
37 |
-
|
38 |
-
|
39 |
-
this.
|
40 |
-
|
41 |
-
|
42 |
-
|
43 |
-
|
|
|
|
|
|
|
44 |
}
|
45 |
|
46 |
private setFormValues(book: Book) {
|
1 |
+
import { Component, effect, input, output } from '@angular/core';
|
2 |
+
import { FormArray, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
3 |
|
4 |
import { Book } from '../../shared/book';
|
5 |
+
import { FormErrorsComponent } from '../form-errors/form-errors.component';
|
6 |
+
import { atLeastOneValue, isbnExists, isbnFormat } from '../shared/validators';
|
7 |
|
8 |
@Component({
|
9 |
selector: 'bm-book-form',
|
10 |
templateUrl: './book-form.component.html',
|
11 |
+
styleUrl: './book-form.component.css',
|
12 |
+
imports: [ReactiveFormsModule, FormErrorsComponent],
|
13 |
+
standalone: true
|
14 |
})
|
15 |
+
export class BookFormComponent {
|
16 |
+
book = input<Book>();
|
17 |
+
submitBook = output<Book>();
|
18 |
|
19 |
form = new FormGroup({
|
20 |
title: new FormControl('', {
|
24 |
subtitle: new FormControl('', { nonNullable: true }),
|
25 |
isbn: new FormControl('', {
|
26 |
nonNullable: true,
|
27 |
+
validators: [Validators.required, isbnFormat],
|
28 |
+
asyncValidators: isbnExists(),
|
|
|
|
|
|
|
29 |
}),
|
30 |
description: new FormControl('', { nonNullable: true }),
|
31 |
published: new FormControl('', { nonNullable: true }),
|
33 |
thumbnailUrl: new FormControl('', { nonNullable: true })
|
34 |
});
|
35 |
|
36 |
+
constructor() {
|
37 |
+
effect(() => {
|
38 |
+
const book = this.book();
|
39 |
+
if (book) {
|
40 |
+
this.setFormValues(book);
|
41 |
+
this.setEditMode(true);
|
42 |
+
} else {
|
43 |
+
this.setEditMode(false);
|
44 |
+
}
|
45 |
+
});
|
46 |
}
|
47 |
|
48 |
private setFormValues(book: Book) {
|
@@ -1,3 +1,3 @@
|
|
1 |
-
|
2 |
-
|
3 |
-
|
1 |
+
@for (error of errors; track $index) {
|
2 |
+
<p class="error">{{ error }}</p>
|
3 |
+
}
|
@@ -1,30 +1,27 @@
|
|
1 |
-
import { Component,
|
2 |
import { FormGroupDirective } from '@angular/forms';
|
3 |
|
4 |
@Component({
|
5 |
selector: 'bm-form-errors',
|
6 |
templateUrl: './form-errors.component.html',
|
7 |
-
|
|
|
8 |
})
|
9 |
export class FormErrorsComponent {
|
10 |
-
|
11 |
-
|
12 |
|
13 |
-
|
14 |
|
15 |
get errors(): string[] {
|
16 |
-
|
17 |
-
return [];
|
18 |
-
}
|
19 |
-
|
20 |
-
const control = this.form.control.get(this.controlName);
|
21 |
|
22 |
if (!control || !control.errors || !control.touched) {
|
23 |
return [];
|
24 |
}
|
25 |
|
26 |
return Object.keys(control.errors).map(errorCode => {
|
27 |
-
return this.messages[errorCode];
|
28 |
});
|
29 |
}
|
30 |
}
|
1 |
+
import { Component, inject, input } from '@angular/core';
|
2 |
import { FormGroupDirective } from '@angular/forms';
|
3 |
|
4 |
@Component({
|
5 |
selector: 'bm-form-errors',
|
6 |
templateUrl: './form-errors.component.html',
|
7 |
+
styleUrl: './form-errors.component.css',
|
8 |
+
standalone: true
|
9 |
})
|
10 |
export class FormErrorsComponent {
|
11 |
+
controlName = input.required<string>();
|
12 |
+
messages = input.required<{ [errorCode: string]: string }>();
|
13 |
|
14 |
+
private form = inject(FormGroupDirective)
|
15 |
|
16 |
get errors(): string[] {
|
17 |
+
const control = this.form.control.get(this.controlName());
|
|
|
|
|
|
|
|
|
18 |
|
19 |
if (!control || !control.errors || !control.touched) {
|
20 |
return [];
|
21 |
}
|
22 |
|
23 |
return Object.keys(control.errors).map(errorCode => {
|
24 |
+
return this.messages()[errorCode];
|
25 |
});
|
26 |
}
|
27 |
}
|
@@ -1,4 +1,4 @@
|
|
1 |
-
import { Injectable } from '@angular/core';
|
2 |
import { AsyncValidatorFn } from '@angular/forms';
|
3 |
import { map } from 'rxjs';
|
4 |
|
@@ -8,8 +8,7 @@ import { BookStoreService } from '../../shared/book-store.service';
|
|
8 |
providedIn: 'root'
|
9 |
})
|
10 |
export class AsyncValidatorsService {
|
11 |
-
|
12 |
-
constructor(private service: BookStoreService) { }
|
13 |
|
14 |
isbnExists(): AsyncValidatorFn {
|
15 |
return (control) => {
|
1 |
+
import { Injectable, inject } from '@angular/core';
|
2 |
import { AsyncValidatorFn } from '@angular/forms';
|
3 |
import { map } from 'rxjs';
|
4 |
|
8 |
providedIn: 'root'
|
9 |
})
|
10 |
export class AsyncValidatorsService {
|
11 |
+
private service = inject(BookStoreService);
|
|
|
12 |
|
13 |
isbnExists(): AsyncValidatorFn {
|
14 |
return (control) => {
|
@@ -1,6 +1,10 @@
|
|
1 |
-
import {
|
|
|
|
|
2 |
|
3 |
-
|
|
|
|
|
4 |
if (!control.value || typeof control.value !== 'string') {
|
5 |
return null;
|
6 |
}
|
@@ -13,9 +17,9 @@ export const isbnFormat: ValidatorFn = function(control) {
|
|
13 |
} else {
|
14 |
return { isbnformat: true };
|
15 |
}
|
16 |
-
}
|
17 |
|
18 |
-
export const atLeastOneValue: ValidatorFn = function(control) {
|
19 |
if (!isFormArray(control)) {
|
20 |
return null;
|
21 |
}
|
@@ -25,4 +29,13 @@ export const atLeastOneValue: ValidatorFn = function(control) {
|
|
25 |
} else {
|
26 |
return { atleastonevalue: true };
|
27 |
}
|
28 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { inject } from '@angular/core';
|
2 |
+
import { AbstractControl, AsyncValidatorFn, isFormArray, ValidatorFn } from '@angular/forms';
|
3 |
+
import { map } from 'rxjs';
|
4 |
|
5 |
+
import { BookStoreService } from '../../shared/book-store.service';
|
6 |
+
|
7 |
+
export const isbnFormat: ValidatorFn = function (control) {
|
8 |
if (!control.value || typeof control.value !== 'string') {
|
9 |
return null;
|
10 |
}
|
17 |
} else {
|
18 |
return { isbnformat: true };
|
19 |
}
|
20 |
+
};
|
21 |
|
22 |
+
export const atLeastOneValue: ValidatorFn = function (control) {
|
23 |
if (!isFormArray(control)) {
|
24 |
return null;
|
25 |
}
|
29 |
} else {
|
30 |
return { atleastonevalue: true };
|
31 |
}
|
32 |
+
};
|
33 |
+
|
34 |
+
export function isbnExists(): AsyncValidatorFn {
|
35 |
+
const service = inject(BookStoreService);
|
36 |
+
return (control: AbstractControl) => {
|
37 |
+
return service
|
38 |
+
.check(control.value)
|
39 |
+
.pipe(map((exists) => (exists ? { isbnexists: true } : null)));
|
40 |
+
};
|
41 |
+
};
|
@@ -3,12 +3,11 @@
|
|
3 |
<a routerLink="/books" routerLinkActive="active" ariaCurrentWhenActive="page">Books</a>
|
4 |
<a routerLink="/admin" routerLinkActive="active" ariaCurrentWhenActive="page">Administration</a>
|
5 |
<div class="actions">
|
6 |
-
|
7 |
-
(click)="auth.
|
8 |
-
|
9 |
-
|
10 |
-
|
11 |
-
*ngIf="auth.isAuthenticated">Logout</button>
|
12 |
</div>
|
13 |
</nav>
|
14 |
|
3 |
<a routerLink="/books" routerLinkActive="active" ariaCurrentWhenActive="page">Books</a>
|
4 |
<a routerLink="/admin" routerLinkActive="active" ariaCurrentWhenActive="page">Administration</a>
|
5 |
<div class="actions">
|
6 |
+
@if (auth.isAuthenticated()) {
|
7 |
+
<button class="red" (click)="auth.logout()">Logout</button>
|
8 |
+
} @else {
|
9 |
+
<button class="green" (click)="auth.login()">Login</button>
|
10 |
+
}
|
|
|
11 |
</div>
|
12 |
</nav>
|
13 |
|
@@ -1,16 +1,10 @@
|
|
1 |
import { TestBed } from '@angular/core/testing';
|
2 |
-
import { RouterTestingModule } from '@angular/router/testing';
|
3 |
import { AppComponent } from './app.component';
|
4 |
|
5 |
describe('AppComponent', () => {
|
6 |
beforeEach(async () => {
|
7 |
await TestBed.configureTestingModule({
|
8 |
-
imports: [
|
9 |
-
RouterTestingModule
|
10 |
-
],
|
11 |
-
declarations: [
|
12 |
-
AppComponent
|
13 |
-
],
|
14 |
}).compileComponents();
|
15 |
});
|
16 |
|
@@ -20,20 +14,16 @@ describe('AppComponent', () => {
|
|
20 |
expect(app).toBeTruthy();
|
21 |
});
|
22 |
|
23 |
-
|
24 |
-
it(`should have as title 'book-monkey'`, () => {
|
25 |
const fixture = TestBed.createComponent(AppComponent);
|
26 |
const app = fixture.componentInstance;
|
27 |
expect(app.title).toEqual('book-monkey');
|
28 |
});
|
29 |
-
*/
|
30 |
|
31 |
-
/*
|
32 |
it('should render title', () => {
|
33 |
const fixture = TestBed.createComponent(AppComponent);
|
34 |
fixture.detectChanges();
|
35 |
const compiled = fixture.nativeElement as HTMLElement;
|
36 |
-
expect(compiled.querySelector('
|
37 |
});
|
38 |
-
*/
|
39 |
});
|
1 |
import { TestBed } from '@angular/core/testing';
|
|
|
2 |
import { AppComponent } from './app.component';
|
3 |
|
4 |
describe('AppComponent', () => {
|
5 |
beforeEach(async () => {
|
6 |
await TestBed.configureTestingModule({
|
7 |
+
imports: [AppComponent],
|
|
|
|
|
|
|
|
|
|
|
8 |
}).compileComponents();
|
9 |
});
|
10 |
|
14 |
expect(app).toBeTruthy();
|
15 |
});
|
16 |
|
17 |
+
it(`should have the 'book-monkey' title`, () => {
|
|
|
18 |
const fixture = TestBed.createComponent(AppComponent);
|
19 |
const app = fixture.componentInstance;
|
20 |
expect(app.title).toEqual('book-monkey');
|
21 |
});
|
|
|
22 |
|
|
|
23 |
it('should render title', () => {
|
24 |
const fixture = TestBed.createComponent(AppComponent);
|
25 |
fixture.detectChanges();
|
26 |
const compiled = fixture.nativeElement as HTMLElement;
|
27 |
+
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, book-monkey');
|
28 |
});
|
|
|
29 |
});
|
@@ -1,12 +1,19 @@
|
|
1 |
-
import { Component } from '@angular/core';
|
|
|
2 |
|
3 |
import { AuthService } from './shared/auth.service';
|
4 |
|
5 |
@Component({
|
6 |
selector: 'bm-root',
|
|
|
|
|
|
|
|
|
|
|
|
|
7 |
templateUrl: './app.component.html',
|
8 |
-
|
9 |
})
|
10 |
export class AppComponent {
|
11 |
-
|
12 |
}
|
1 |
+
import { Component, inject } from '@angular/core';
|
2 |
+
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
3 |
|
4 |
import { AuthService } from './shared/auth.service';
|
5 |
|
6 |
@Component({
|
7 |
selector: 'bm-root',
|
8 |
+
standalone: true,
|
9 |
+
imports: [
|
10 |
+
RouterOutlet,
|
11 |
+
RouterLink,
|
12 |
+
RouterLinkActive
|
13 |
+
],
|
14 |
templateUrl: './app.component.html',
|
15 |
+
styleUrl: './app.component.css'
|
16 |
})
|
17 |
export class AppComponent {
|
18 |
+
auth = inject(AuthService)
|
19 |
}
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
|
2 |
+
import { provideServerRendering } from '@angular/platform-server';
|
3 |
+
import { appConfig } from './app.config';
|
4 |
+
|
5 |
+
const serverConfig: ApplicationConfig = {
|
6 |
+
providers: [
|
7 |
+
provideServerRendering()
|
8 |
+
]
|
9 |
+
};
|
10 |
+
|
11 |
+
export const config = mergeApplicationConfig(appConfig, serverConfig);
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
|
2 |
+
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
|
3 |
+
import { provideClientHydration } from '@angular/platform-browser';
|
4 |
+
import { provideRouter, withComponentInputBinding } from '@angular/router';
|
5 |
+
|
6 |
+
import { routes } from './app.routes';
|
7 |
+
import { authInterceptor } from './shared/auth.interceptor';
|
8 |
+
|
9 |
+
export const appConfig: ApplicationConfig = {
|
10 |
+
providers: [
|
11 |
+
provideZoneChangeDetection({ eventCoalescing: true }),
|
12 |
+
provideRouter(routes, withComponentInputBinding()),
|
13 |
+
provideHttpClient(
|
14 |
+
withFetch(),
|
15 |
+
withInterceptors([authInterceptor])
|
16 |
+
),
|
17 |
+
provideClientHydration()
|
18 |
+
]
|
19 |
+
};
|
@@ -1,31 +0,0 @@
|
|
1 |
-
import { NgModule } from '@angular/core';
|
2 |
-
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
|
3 |
-
import { BrowserModule } from '@angular/platform-browser';
|
4 |
-
|
5 |
-
import { AppRoutingModule } from './app-routing.module';
|
6 |
-
import { AppComponent } from './app.component';
|
7 |
-
import { HomeComponent } from './home/home.component';
|
8 |
-
import { SearchComponent } from './search/search.component';
|
9 |
-
import { AuthInterceptor } from './shared/auth.interceptor';
|
10 |
-
|
11 |
-
@NgModule({
|
12 |
-
declarations: [
|
13 |
-
AppComponent,
|
14 |
-
HomeComponent,
|
15 |
-
SearchComponent,
|
16 |
-
],
|
17 |
-
imports: [
|
18 |
-
BrowserModule,
|
19 |
-
AppRoutingModule,
|
20 |
-
HttpClientModule,
|
21 |
-
],
|
22 |
-
providers: [
|
23 |
-
{
|
24 |
-
provide: HTTP_INTERCEPTORS,
|
25 |
-
useClass: AuthInterceptor,
|
26 |
-
multi: true
|
27 |
-
}
|
28 |
-
],
|
29 |
-
bootstrap: [AppComponent]
|
30 |
-
})
|
31 |
-
export class AppModule { }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,10 +1,9 @@
|
|
1 |
-
import {
|
2 |
-
import { RouterModule, Routes } from '@angular/router';
|
3 |
|
4 |
import { HomeComponent } from './home/home.component';
|
5 |
import { authGuard } from './shared/auth.guard';
|
6 |
|
7 |
-
const routes: Routes = [
|
8 |
{
|
9 |
path: '',
|
10 |
redirectTo: 'home',
|
@@ -20,13 +19,7 @@ const routes: Routes = [
|
|
20 |
},
|
21 |
{
|
22 |
path: 'admin',
|
23 |
-
loadChildren: () => import('./admin/admin.
|
24 |
canActivate: [authGuard]
|
25 |
}
|
26 |
];
|
27 |
-
|
28 |
-
@NgModule({
|
29 |
-
imports: [RouterModule.forRoot(routes)],
|
30 |
-
exports: [RouterModule]
|
31 |
-
})
|
32 |
-
export class AppRoutingModule { }
|
1 |
+
import { Routes } from '@angular/router';
|
|
|
2 |
|
3 |
import { HomeComponent } from './home/home.component';
|
4 |
import { authGuard } from './shared/auth.guard';
|
5 |
|
6 |
+
export const routes: Routes = [
|
7 |
{
|
8 |
path: '',
|
9 |
redirectTo: 'home',
|
19 |
},
|
20 |
{
|
21 |
path: 'admin',
|
22 |
+
loadChildren: () => import('./admin/admin.routes').then(m => m.ADMIN_ROUTES),
|
23 |
canActivate: [authGuard]
|
24 |
}
|
25 |
];
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,27 +1,34 @@
|
|
1 |
-
|
|
|
2 |
<h1>{{ book.title }}</h1>
|
3 |
-
|
|
|
|
|
4 |
<div class="header">
|
5 |
<div>
|
6 |
<h2>Authors</h2>
|
7 |
<ul>
|
8 |
-
|
|
|
|
|
9 |
</ul>
|
10 |
</div>
|
11 |
<div>
|
12 |
<h2>ISBN</h2>
|
13 |
{{ book.isbn | isbn }}
|
14 |
</div>
|
15 |
-
|
|
|
16 |
<h2>Published</h2>
|
17 |
{{ book.published | date:'longDate' }}
|
18 |
</div>
|
|
|
19 |
</div>
|
20 |
<h2>Description</h2>
|
21 |
<p>{{ book.description }}</p>
|
22 |
-
|
23 |
-
|
24 |
-
|
25 |
<a class="button arrow-left" routerLink="..">Back to list</a>
|
26 |
<ng-container *bmLoggedinOnly>
|
27 |
<button class="red" bmConfirm="Remove book?" (confirm)="removeBook(book.isbn)">
|
@@ -33,3 +40,4 @@
|
|
33 |
</a>
|
34 |
</ng-container>
|
35 |
</div>
|
|
1 |
+
@if (book$ | async; as book) {
|
2 |
+
<div class="details">
|
3 |
<h1>{{ book.title }}</h1>
|
4 |
+
@if (book.subtitle) {
|
5 |
+
<p role="doc-subtitle">{{ book.subtitle }}</p>
|
6 |
+
}
|
7 |
<div class="header">
|
8 |
<div>
|
9 |
<h2>Authors</h2>
|
10 |
<ul>
|
11 |
+
@for (author of book.authors; track author) {
|
12 |
+
<li>{{ author }}</li>
|
13 |
+
}
|
14 |
</ul>
|
15 |
</div>
|
16 |
<div>
|
17 |
<h2>ISBN</h2>
|
18 |
{{ book.isbn | isbn }}
|
19 |
</div>
|
20 |
+
@if (book.published) {
|
21 |
+
<div>
|
22 |
<h2>Published</h2>
|
23 |
{{ book.published | date:'longDate' }}
|
24 |
</div>
|
25 |
+
}
|
26 |
</div>
|
27 |
<h2>Description</h2>
|
28 |
<p>{{ book.description }}</p>
|
29 |
+
@if (book.thumbnailUrl) {
|
30 |
+
<img alt="Cover" [ngSrc]="book.thumbnailUrl" width="200" height="250" priority>
|
31 |
+
}
|
32 |
<a class="button arrow-left" routerLink="..">Back to list</a>
|
33 |
<ng-container *bmLoggedinOnly>
|
34 |
<button class="red" bmConfirm="Remove book?" (confirm)="removeBook(book.isbn)">
|
40 |
</a>
|
41 |
</ng-container>
|
42 |
</div>
|
43 |
+
}
|
@@ -1,35 +1,36 @@
|
|
1 |
-
import {
|
2 |
-
import {
|
3 |
-
import {
|
4 |
-
import {
|
|
|
5 |
|
6 |
import { BookStoreService } from '../../shared/book-store.service';
|
7 |
-
import {
|
8 |
import { IsbnPipe } from '../../shared/isbn.pipe';
|
9 |
import { LoggedinOnlyDirective } from '../../shared/loggedin-only.directive';
|
10 |
-
import { ConfirmDirective } from '../../shared/confirm.directive';
|
11 |
|
12 |
@Component({
|
13 |
selector: 'bm-book-details',
|
14 |
templateUrl: './book-details.component.html',
|
15 |
-
|
16 |
standalone: true,
|
17 |
imports: [
|
18 |
-
|
19 |
-
|
|
|
|
|
|
|
|
|
|
|
20 |
]
|
21 |
})
|
22 |
export class BookDetailsComponent {
|
23 |
-
|
24 |
-
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
-
) {
|
30 |
-
const isbn = this.route.snapshot.paramMap.get('isbn')!;
|
31 |
-
this.book$ = this.service.getSingle(isbn);
|
32 |
-
}
|
33 |
|
34 |
removeBook(isbn: string) {
|
35 |
this.service.remove(isbn).subscribe(() => {
|
1 |
+
import { AsyncPipe, DatePipe, NgOptimizedImage } from '@angular/common';
|
2 |
+
import { Component, inject, input } from '@angular/core';
|
3 |
+
import { toObservable } from '@angular/core/rxjs-interop';
|
4 |
+
import { Router, RouterLink } from '@angular/router';
|
5 |
+
import { switchMap } from 'rxjs';
|
6 |
|
7 |
import { BookStoreService } from '../../shared/book-store.service';
|
8 |
+
import { ConfirmDirective } from '../../shared/confirm.directive';
|
9 |
import { IsbnPipe } from '../../shared/isbn.pipe';
|
10 |
import { LoggedinOnlyDirective } from '../../shared/loggedin-only.directive';
|
|
|
11 |
|
12 |
@Component({
|
13 |
selector: 'bm-book-details',
|
14 |
templateUrl: './book-details.component.html',
|
15 |
+
styleUrl: './book-details.component.css',
|
16 |
standalone: true,
|
17 |
imports: [
|
18 |
+
DatePipe,
|
19 |
+
AsyncPipe,
|
20 |
+
NgOptimizedImage,
|
21 |
+
RouterLink,
|
22 |
+
IsbnPipe,
|
23 |
+
LoggedinOnlyDirective,
|
24 |
+
ConfirmDirective
|
25 |
]
|
26 |
})
|
27 |
export class BookDetailsComponent {
|
28 |
+
private service = inject(BookStoreService);
|
29 |
+
private router = inject(Router);
|
30 |
+
isbn = input.required<string>();
|
31 |
+
book$ = toObservable(this.isbn).pipe(
|
32 |
+
switchMap(isbn => this.service.getSingle(isbn))
|
33 |
+
);
|
|
|
|
|
|
|
|
|
34 |
|
35 |
removeBook(isbn: string) {
|
36 |
this.service.remove(isbn).subscribe(() => {
|
@@ -1,9 +1,12 @@
|
|
1 |
<h1>Books</h1>
|
2 |
-
|
3 |
-
<
|
|
|
|
|
4 |
<bm-book-list-item [book]="book"></bm-book-list-item>
|
5 |
</li>
|
6 |
-
|
7 |
-
|
8 |
-
|
9 |
</ul>
|
|
1 |
<h1>Books</h1>
|
2 |
+
@if (books(); as books) {
|
3 |
+
<ul class="book-list">
|
4 |
+
@for (book of books; track book.isbn) {
|
5 |
+
<li>
|
6 |
<bm-book-list-item [book]="book"></bm-book-list-item>
|
7 |
</li>
|
8 |
+
} @empty {
|
9 |
+
<li>No books available.</li>
|
10 |
+
}
|
11 |
</ul>
|
12 |
+
}
|
@@ -1,25 +1,17 @@
|
|
1 |
-
import {
|
2 |
-
import {
|
3 |
-
import { Observable } from 'rxjs';
|
4 |
|
5 |
-
import { Book } from '../../shared/book';
|
6 |
import { BookStoreService } from '../../shared/book-store.service';
|
7 |
import { BookListItemComponent } from '../book-list-item/book-list-item.component';
|
8 |
|
9 |
@Component({
|
10 |
selector: 'bm-book-list',
|
11 |
templateUrl: './book-list.component.html',
|
12 |
-
|
13 |
standalone: true,
|
14 |
-
imports: [
|
15 |
-
NgIf, NgFor, AsyncPipe,
|
16 |
-
BookListItemComponent
|
17 |
-
]
|
18 |
})
|
19 |
export class BookListComponent {
|
20 |
-
books
|
21 |
-
|
22 |
-
constructor(private service: BookStoreService) {
|
23 |
-
this.books$ = this.service.getAll();
|
24 |
-
}
|
25 |
}
|
|
1 |
+
import { Component, inject } from '@angular/core';
|
2 |
+
import { toSignal } from '@angular/core/rxjs-interop';
|
|
|
3 |
|
|
|
4 |
import { BookStoreService } from '../../shared/book-store.service';
|
5 |
import { BookListItemComponent } from '../book-list-item/book-list-item.component';
|
6 |
|
7 |
@Component({
|
8 |
selector: 'bm-book-list',
|
9 |
templateUrl: './book-list.component.html',
|
10 |
+
styleUrl: './book-list.component.css',
|
11 |
standalone: true,
|
12 |
+
imports: [BookListItemComponent]
|
|
|
|
|
|
|
13 |
})
|
14 |
export class BookListComponent {
|
15 |
+
books = toSignal(inject(BookStoreService).getAll());
|
|
|
|
|
|
|
|
|
16 |
}
|
17 |
+
|
@@ -1,13 +1,21 @@
|
|
1 |
-
|
2 |
-
|
|
|
|
|
|
|
3 |
<h2>{{ book.title }}</h2>
|
4 |
-
|
|
|
5 |
{{ book.subtitle }}
|
6 |
</p>
|
|
|
7 |
<ul class="comma-list">
|
8 |
-
|
|
|
9 |
{{ author }}
|
10 |
</li>
|
|
|
11 |
</ul>
|
12 |
<div>ISBN {{ book.isbn | isbn }}</div>
|
13 |
</a>
|
|
1 |
+
@if(book(); as book) {
|
2 |
+
<a [routerLink]="book.isbn" class="list-item">
|
3 |
+
@if(book.thumbnailUrl) {
|
4 |
+
<img alt="Cover" [ngSrc]="book.thumbnailUrl" width="120" height="175">
|
5 |
+
}
|
6 |
<h2>{{ book.title }}</h2>
|
7 |
+
@if(book.subtitle) {
|
8 |
+
<p role="doc-subtitle">
|
9 |
{{ book.subtitle }}
|
10 |
</p>
|
11 |
+
}
|
12 |
<ul class="comma-list">
|
13 |
+
@for (author of book.authors; track author) {
|
14 |
+
<li>
|
15 |
{{ author }}
|
16 |
</li>
|
17 |
+
}
|
18 |
</ul>
|
19 |
<div>ISBN {{ book.isbn | isbn }}</div>
|
20 |
</a>
|
21 |
+
}
|
@@ -1,6 +1,6 @@
|
|
1 |
-
import {
|
2 |
-
import { Component, Input } from '@angular/core';
|
3 |
import { RouterLink } from '@angular/router';
|
|
|
4 |
|
5 |
import { IsbnPipe } from '../../shared/isbn.pipe';
|
6 |
import { Book } from '../../shared/book';
|
@@ -8,10 +8,10 @@ import { Book } from '../../shared/book';
|
|
8 |
@Component({
|
9 |
selector: 'bm-book-list-item',
|
10 |
templateUrl: './book-list-item.component.html',
|
11 |
-
|
12 |
standalone: true,
|
13 |
-
imports: [
|
14 |
})
|
15 |
export class BookListItemComponent {
|
16 |
-
|
17 |
}
|
1 |
+
import { Component, input } from '@angular/core';
|
|
|
2 |
import { RouterLink } from '@angular/router';
|
3 |
+
import { NgOptimizedImage } from '@angular/common';
|
4 |
|
5 |
import { IsbnPipe } from '../../shared/isbn.pipe';
|
6 |
import { Book } from '../../shared/book';
|
8 |
@Component({
|
9 |
selector: 'bm-book-list-item',
|
10 |
templateUrl: './book-list-item.component.html',
|
11 |
+
styleUrl: './book-list-item.component.css',
|
12 |
standalone: true,
|
13 |
+
imports: [RouterLink, IsbnPipe, NgOptimizedImage]
|
14 |
})
|
15 |
export class BookListItemComponent {
|
16 |
+
book = input.required<Book>();
|
17 |
}
|
@@ -1,8 +1,12 @@
|
|
1 |
import { Component } from '@angular/core';
|
|
|
|
|
2 |
|
3 |
@Component({
|
4 |
selector: 'bm-home',
|
5 |
templateUrl: './home.component.html',
|
6 |
-
|
|
|
|
|
7 |
})
|
8 |
export class HomeComponent {}
|
1 |
import { Component } from '@angular/core';
|
2 |
+
import { RouterLink } from '@angular/router';
|
3 |
+
import { SearchComponent } from '../search/search.component';
|
4 |
|
5 |
@Component({
|
6 |
selector: 'bm-home',
|
7 |
templateUrl: './home.component.html',
|
8 |
+
styleUrl: './home.component.css',
|
9 |
+
imports: [RouterLink, SearchComponent],
|
10 |
+
standalone: true
|
11 |
})
|
12 |
export class HomeComponent {}
|
@@ -1,16 +1,21 @@
|
|
1 |
<input type="search"
|
2 |
autocomplete="off"
|
3 |
aria-label="Search"
|
4 |
-
[class.loading]="isLoading"
|
5 |
#searchInput
|
6 |
(input)="input$.next(searchInput.value)">
|
7 |
|
8 |
-
|
9 |
-
|
|
|
|
|
10 |
<a [routerLink]="['/books', book.isbn]">
|
11 |
{{ book.title }}
|
12 |
<p role="doc-subtitle">{{ book.subtitle }}</p>
|
13 |
</a>
|
14 |
</li>
|
15 |
-
|
|
|
|
|
16 |
</ul>
|
|
1 |
<input type="search"
|
2 |
autocomplete="off"
|
3 |
aria-label="Search"
|
4 |
+
[class.loading]="isLoading()"
|
5 |
#searchInput
|
6 |
(input)="input$.next(searchInput.value)">
|
7 |
|
8 |
+
@if (results$ | async; as results) {
|
9 |
+
<ul class="search-results">
|
10 |
+
@for (book of results; track book.isbn) {
|
11 |
+
<li>
|
12 |
<a [routerLink]="['/books', book.isbn]">
|
13 |
{{ book.title }}
|
14 |
<p role="doc-subtitle">{{ book.subtitle }}</p>
|
15 |
</a>
|
16 |
</li>
|
17 |
+
} @empty {
|
18 |
+
<li>No results</li>
|
19 |
+
}
|
20 |
</ul>
|
21 |
+
}
|
@@ -1,5 +1,7 @@
|
|
1 |
-
import {
|
2 |
-
import {
|
|
|
|
|
3 |
|
4 |
import { Book } from '../shared/book';
|
5 |
import { BookStoreService } from '../shared/book-store.service';
|
@@ -7,22 +9,24 @@ import { BookStoreService } from '../shared/book-store.service';
|
|
7 |
@Component({
|
8 |
selector: 'bm-search',
|
9 |
templateUrl: './search.component.html',
|
10 |
-
|
|
|
|
|
11 |
})
|
12 |
export class SearchComponent {
|
|
|
13 |
input$ = new Subject<string>();
|
14 |
-
isLoading = false;
|
15 |
-
|
16 |
results$: Observable<Book[]>;
|
17 |
|
18 |
-
constructor(
|
19 |
this.results$ = this.input$.pipe(
|
20 |
filter(term => term.length >= 3),
|
21 |
debounceTime(500),
|
22 |
distinctUntilChanged(),
|
23 |
-
tap(() => this.isLoading
|
24 |
switchMap(term => this.service.getAllSearch(term)),
|
25 |
-
tap(() => this.isLoading
|
26 |
);
|
27 |
}
|
28 |
}
|
1 |
+
import { AsyncPipe } from '@angular/common';
|
2 |
+
import { Component, inject, signal } from '@angular/core';
|
3 |
+
import { RouterLink } from '@angular/router';
|
4 |
+
import { debounceTime, distinctUntilChanged, filter, Observable, Subject, switchMap, tap } from 'rxjs';
|
5 |
|
6 |
import { Book } from '../shared/book';
|
7 |
import { BookStoreService } from '../shared/book-store.service';
|
9 |
@Component({
|
10 |
selector: 'bm-search',
|
11 |
templateUrl: './search.component.html',
|
12 |
+
styleUrl: './search.component.css',
|
13 |
+
imports: [RouterLink, AsyncPipe],
|
14 |
+
standalone: true
|
15 |
})
|
16 |
export class SearchComponent {
|
17 |
+
private service = inject(BookStoreService)
|
18 |
input$ = new Subject<string>();
|
19 |
+
isLoading = signal(false);
|
|
|
20 |
results$: Observable<Book[]>;
|
21 |
|
22 |
+
constructor() {
|
23 |
this.results$ = this.input$.pipe(
|
24 |
filter(term => term.length >= 3),
|
25 |
debounceTime(500),
|
26 |
distinctUntilChanged(),
|
27 |
+
tap(() => this.isLoading.set(true)),
|
28 |
switchMap(term => this.service.getAllSearch(term)),
|
29 |
+
tap(() => this.isLoading.set(false))
|
30 |
);
|
31 |
}
|
32 |
}
|
@@ -8,7 +8,7 @@ export const authGuard: CanActivateFn = () => {
|
|
8 |
const authService = inject(AuthService);
|
9 |
const router = inject(Router);
|
10 |
|
11 |
-
if (authService.isAuthenticated) {
|
12 |
return true;
|
13 |
} else {
|
14 |
window.alert('Not logged in!');
|
8 |
const authService = inject(AuthService);
|
9 |
const router = inject(Router);
|
10 |
|
11 |
+
if (authService.isAuthenticated()) {
|
12 |
return true;
|
13 |
} else {
|
14 |
window.alert('Not logged in!');
|
@@ -1,36 +1,23 @@
|
|
1 |
-
import {
|
2 |
-
import {
|
3 |
-
HttpRequest,
|
4 |
-
HttpHandler,
|
5 |
-
HttpEvent,
|
6 |
-
HttpInterceptor
|
7 |
-
} from '@angular/common/http';
|
8 |
-
import { Observable } from 'rxjs';
|
9 |
-
import { AuthService } from './auth.service';
|
10 |
-
|
11 |
-
@Injectable()
|
12 |
-
export class AuthInterceptor implements HttpInterceptor {
|
13 |
|
14 |
-
|
15 |
|
16 |
-
|
17 |
-
|
18 |
-
|
19 |
-
): Observable<HttpEvent<unknown>> {
|
20 |
-
const token = '1234567890';
|
21 |
|
22 |
-
|
23 |
-
|
24 |
-
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
}
|
35 |
}
|
36 |
}
|
1 |
+
import { inject } from '@angular/core';
|
2 |
+
import { HttpInterceptorFn } from '@angular/common/http';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
3 |
|
4 |
+
import { AuthService } from './auth.service';
|
5 |
|
6 |
+
export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
7 |
+
const authService = inject(AuthService);
|
8 |
+
const token = '1234567890';
|
|
|
|
|
9 |
|
10 |
+
if (authService.isAuthenticated()) {
|
11 |
+
// Token in Header einfügen
|
12 |
+
const reqWithToken = req.clone({
|
13 |
+
setHeaders: {
|
14 |
+
Authorization: `Bearer ${token}`
|
15 |
+
}
|
16 |
+
});
|
17 |
|
18 |
+
return next(reqWithToken);
|
19 |
+
} else {
|
20 |
+
// Request unverändert weitergeben
|
21 |
+
return next(req);
|
|
|
22 |
}
|
23 |
}
|
@@ -1,22 +1,18 @@
|
|
1 |
-
import { Injectable } from '@angular/core';
|
2 |
-
import {
|
3 |
|
4 |
@Injectable({
|
5 |
providedIn: 'root'
|
6 |
})
|
7 |
export class AuthService {
|
8 |
-
|
9 |
-
readonly isAuthenticated$ = this.
|
10 |
-
|
11 |
-
get isAuthenticated() {
|
12 |
-
return this._isAuthenticated$.value;
|
13 |
-
}
|
14 |
|
15 |
login() {
|
16 |
-
this.
|
17 |
}
|
18 |
|
19 |
logout() {
|
20 |
-
this.
|
21 |
}
|
22 |
}
|
1 |
+
import { Injectable, signal } from '@angular/core';
|
2 |
+
import { toObservable } from '@angular/core/rxjs-interop';
|
3 |
|
4 |
@Injectable({
|
5 |
providedIn: 'root'
|
6 |
})
|
7 |
export class AuthService {
|
8 |
+
readonly isAuthenticated = signal(true);
|
9 |
+
readonly isAuthenticated$ = toObservable(this.isAuthenticated);
|
|
|
|
|
|
|
|
|
10 |
|
11 |
login() {
|
12 |
+
this.isAuthenticated.set(true);
|
13 |
}
|
14 |
|
15 |
logout() {
|
16 |
+
this.isAuthenticated.set(false);
|
17 |
}
|
18 |
}
|
@@ -1,5 +1,5 @@
|
|
1 |
import { HttpClient } from '@angular/common/http';
|
2 |
-
import { Injectable } from '@angular/core';
|
3 |
import { Observable, catchError, of } from 'rxjs';
|
4 |
|
5 |
import { Book } from './book';
|
@@ -9,8 +9,7 @@ import { Book } from './book';
|
|
9 |
})
|
10 |
export class BookStoreService {
|
11 |
private apiUrl = 'https://api5.angular-buch.com';
|
12 |
-
|
13 |
-
constructor(private http: HttpClient) {}
|
14 |
|
15 |
getAll(): Observable<Book[]> {
|
16 |
return this.http.get<Book[]>(`${this.apiUrl}/books`).pipe(
|
1 |
import { HttpClient } from '@angular/common/http';
|
2 |
+
import { Injectable, inject } from '@angular/core';
|
3 |
import { Observable, catchError, of } from 'rxjs';
|
4 |
|
5 |
import { Book } from './book';
|
9 |
})
|
10 |
export class BookStoreService {
|
11 |
private apiUrl = 'https://api5.angular-buch.com';
|
12 |
+
private http = inject(HttpClient);
|
|
|
13 |
|
14 |
getAll(): Observable<Book[]> {
|
15 |
return this.http.get<Book[]>(`${this.apiUrl}/books`).pipe(
|
@@ -1,15 +1,15 @@
|
|
1 |
-
import { Directive,
|
2 |
|
3 |
@Directive({
|
4 |
selector: '[bmConfirm]',
|
5 |
standalone: true
|
6 |
})
|
7 |
export class ConfirmDirective {
|
8 |
-
|
9 |
-
|
10 |
|
11 |
@HostListener('click') onClick() {
|
12 |
-
if (window.confirm(this.confirmText)) {
|
13 |
this.confirm.emit();
|
14 |
}
|
15 |
}
|
1 |
+
import { Directive, HostListener, input, output } from '@angular/core';
|
2 |
|
3 |
@Directive({
|
4 |
selector: '[bmConfirm]',
|
5 |
standalone: true
|
6 |
})
|
7 |
export class ConfirmDirective {
|
8 |
+
confirmText = input.required<string>({ alias: 'bmConfirm' });
|
9 |
+
confirm = output();
|
10 |
|
11 |
@HostListener('click') onClick() {
|
12 |
+
if (window.confirm(this.confirmText())) {
|
13 |
this.confirm.emit();
|
14 |
}
|
15 |
}
|
@@ -1,5 +1,4 @@
|
|
1 |
-
import { Directive,
|
2 |
-
import { Subject, takeUntil } from 'rxjs';
|
3 |
|
4 |
import { AuthService } from './auth.service';
|
5 |
|
@@ -7,26 +6,18 @@ import { AuthService } from './auth.service';
|
|
7 |
selector: '[bmLoggedinOnly]',
|
8 |
standalone: true
|
9 |
})
|
10 |
-
export class LoggedinOnlyDirective
|
11 |
-
private
|
|
|
|
|
12 |
|
13 |
-
constructor(
|
14 |
-
|
15 |
-
|
16 |
-
private authService: AuthService
|
17 |
-
) {
|
18 |
-
this.authService.isAuthenticated$.pipe(
|
19 |
-
takeUntil(this.destroy$)
|
20 |
-
).subscribe(isAuthenticated => {
|
21 |
-
if (isAuthenticated) {
|
22 |
this.viewContainer.createEmbeddedView(this.template);
|
23 |
} else {
|
24 |
this.viewContainer.clear();
|
25 |
}
|
26 |
});
|
27 |
}
|
28 |
-
|
29 |
-
ngOnDestroy(): void {
|
30 |
-
this.destroy$.next();
|
31 |
-
}
|
32 |
}
|
1 |
+
import { Directive, TemplateRef, ViewContainerRef, effect, inject } from '@angular/core';
|
|
|
2 |
|
3 |
import { AuthService } from './auth.service';
|
4 |
|
6 |
selector: '[bmLoggedinOnly]',
|
7 |
standalone: true
|
8 |
})
|
9 |
+
export class LoggedinOnlyDirective {
|
10 |
+
private template = inject(TemplateRef);
|
11 |
+
private viewContainer = inject(ViewContainerRef);
|
12 |
+
private authService = inject(AuthService);
|
13 |
|
14 |
+
constructor() {
|
15 |
+
effect(() => {
|
16 |
+
if (this.authService.isAuthenticated()) {
|
|
|
|
|
|
|
|
|
|
|
|
|
17 |
this.viewContainer.createEmbeddedView(this.template);
|
18 |
} else {
|
19 |
this.viewContainer.clear();
|
20 |
}
|
21 |
});
|
22 |
}
|
|
|
|
|
|
|
|
|
23 |
}
|
@@ -6,6 +6,7 @@
|
|
6 |
<base href="/">
|
7 |
<meta name="viewport" content="width=device-width, initial-scale=1">
|
8 |
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
|
|
9 |
</head>
|
10 |
<body>
|
11 |
<bm-root>
|
6 |
<base href="/">
|
7 |
<meta name="viewport" content="width=device-width, initial-scale=1">
|
8 |
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
9 |
+
<link rel="preconnect" href="https://cdn.ng-buch.de">
|
10 |
</head>
|
11 |
<body>
|
12 |
<bm-root>
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { bootstrapApplication } from '@angular/platform-browser';
|
2 |
+
import { AppComponent } from './app/app.component';
|
3 |
+
import { config } from './app/app.config.server';
|
4 |
+
|
5 |
+
const bootstrap = () => bootstrapApplication(AppComponent, config);
|
6 |
+
|
7 |
+
export default bootstrap;
|
@@ -1,8 +1,6 @@
|
|
1 |
-
import {
|
|
|
|
|
2 |
|
3 |
-
|
4 |
-
|
5 |
-
platformBrowserDynamic().bootstrapModule(AppModule, {
|
6 |
-
ngZoneEventCoalescing: true
|
7 |
-
})
|
8 |
-
.catch(err => console.error(err));
|
1 |
+
import { bootstrapApplication } from '@angular/platform-browser';
|
2 |
+
import { appConfig } from './app/app.config';
|
3 |
+
import { AppComponent } from './app/app.component';
|
4 |
|
5 |
+
bootstrapApplication(AppComponent, appConfig)
|
6 |
+
.catch((err) => console.error(err));
|
|
|
|
|
|
|
|
@@ -4,10 +4,14 @@
|
|
4 |
"extends": "./tsconfig.json",
|
5 |
"compilerOptions": {
|
6 |
"outDir": "./out-tsc/app",
|
7 |
-
"types": [
|
|
|
|
|
8 |
},
|
9 |
"files": [
|
10 |
-
"src/main.ts"
|
|
|
|
|
11 |
],
|
12 |
"include": [
|
13 |
"src/**/*.d.ts"
|
4 |
"extends": "./tsconfig.json",
|
5 |
"compilerOptions": {
|
6 |
"outDir": "./out-tsc/app",
|
7 |
+
"types": [
|
8 |
+
"node"
|
9 |
+
]
|
10 |
},
|
11 |
"files": [
|
12 |
+
"src/main.ts",
|
13 |
+
"src/main.server.ts",
|
14 |
+
"server.ts"
|
15 |
],
|
16 |
"include": [
|
17 |
"src/**/*.d.ts"
|