diff --git a/angular.json b/angular.json
index b5db2fb..ab85750 100644
--- a/angular.json
+++ b/angular.json
@@ -31,6 +31,7 @@
"src/assets"
],
"styles": [
+ "node_modules/@angular/material/prebuilt-themes/azure-blue.css",
"src/styles.scss"
],
"scripts": [],
@@ -93,6 +94,7 @@
"src/assets"
],
"styles": [
+ "node_modules/@angular/material/prebuilt-themes/azure-blue.css",
"src/styles.scss"
],
"scripts": []
@@ -113,6 +115,7 @@
"cli": {
"schematicCollections": [
"@angular-eslint/schematics"
- ]
+ ],
+ "analytics": false
}
}
diff --git a/package-lock.json b/package-lock.json
index 5e4c46a..6411c56 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,13 +9,22 @@
"version": "0.0.0",
"dependencies": {
"@angular/animations": "^19.2.10",
+ "@angular/cdk": "^19.2.10",
"@angular/common": "^19.2.10",
"@angular/compiler": "^19.2.10",
"@angular/core": "^19.2.10",
"@angular/forms": "^19.2.10",
+ "@angular/material": "^19.2.17",
"@angular/platform-browser": "^19.2.10",
"@angular/platform-browser-dynamic": "^19.2.10",
"@angular/router": "^19.2.10",
+ "@ngrx/component-store": "^19.2.0",
+ "@ngrx/effects": "^19.2.0",
+ "@ngrx/entity": "^19.2.0",
+ "@ngrx/router-store": "^19.2.0",
+ "@ngrx/store": "^19.2.0",
+ "add": "^2.0.6",
+ "crypto-js": "^4.2.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
@@ -29,6 +38,7 @@
"@angular-eslint/template-parser": "19.4.0",
"@angular/cli": "^19.2.12",
"@angular/compiler-cli": "^19.2.10",
+ "@types/crypto-js": "^4.2.2",
"@types/jasmine": "~4.3.0",
"@typescript-eslint/eslint-plugin": "8.32.1",
"@typescript-eslint/parser": "8.32.1",
@@ -1249,6 +1259,21 @@
}
}
},
+ "node_modules/@angular/cdk": {
+ "version": "19.2.17",
+ "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-19.2.17.tgz",
+ "integrity": "sha512-3jG33S+5+kqymCRwQlcSEWlY5rYwkKxe0onln+NXxT0/kteR02vWvv1+Li4/QqSr5JvsGHEhAFsZaR9QtOzbdA==",
+ "license": "MIT",
+ "dependencies": {
+ "parse5": "^7.1.2",
+ "tslib": "^2.3.0"
+ },
+ "peerDependencies": {
+ "@angular/common": "^19.0.0 || ^20.0.0",
+ "@angular/core": "^19.0.0 || ^20.0.0",
+ "rxjs": "^6.5.3 || ^7.4.0"
+ }
+ },
"node_modules/@angular/cli": {
"version": "19.2.12",
"resolved": "https://registry.npmjs.org/@angular/cli/-/cli-19.2.12.tgz",
@@ -1510,6 +1535,23 @@
"rxjs": "^6.5.3 || ^7.4.0"
}
},
+ "node_modules/@angular/material": {
+ "version": "19.2.17",
+ "resolved": "https://registry.npmjs.org/@angular/material/-/material-19.2.17.tgz",
+ "integrity": "sha512-IyA+KP+uUj3r9loqGJrj7qAiEBckj7EVIdV0jlYwqWIUyKWeJ3R88GmLPMH2BgtBU3R/WkS2blXDI0yvRhKfww==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.3.0"
+ },
+ "peerDependencies": {
+ "@angular/cdk": "19.2.17",
+ "@angular/common": "^19.0.0 || ^20.0.0",
+ "@angular/core": "^19.0.0 || ^20.0.0",
+ "@angular/forms": "^19.0.0 || ^20.0.0",
+ "@angular/platform-browser": "^19.0.0 || ^20.0.0",
+ "rxjs": "^6.5.3 || ^7.4.0"
+ }
+ },
"node_modules/@angular/platform-browser": {
"version": "19.2.10",
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-19.2.10.tgz",
@@ -4957,6 +4999,76 @@
"node": ">= 10"
}
},
+ "node_modules/@ngrx/component-store": {
+ "version": "19.2.0",
+ "resolved": "https://registry.npmjs.org/@ngrx/component-store/-/component-store-19.2.0.tgz",
+ "integrity": "sha512-PMeJkikg6+06BrwAs7YKPXWNTWaHpdNZAtzdDcz2ad8Nv3jLhMk8OXHD03ODfNYm2DGy3Glm7ayv2yckjiy/hw==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "@angular/core": "^19.0.0",
+ "rxjs": "^6.5.3 || ^7.5.0"
+ }
+ },
+ "node_modules/@ngrx/effects": {
+ "version": "19.2.0",
+ "resolved": "https://registry.npmjs.org/@ngrx/effects/-/effects-19.2.0.tgz",
+ "integrity": "sha512-DIoFdEdSehAMHUNTWIdl94HjhSh1ZRx0Rgtgp1TjHHyjLiS+vbMmDgPjrCkBv5lT/pEaKbHKnYxjY3CQiW2Hsg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "@angular/core": "^19.0.0",
+ "@ngrx/store": "19.2.0",
+ "rxjs": "^6.5.3 || ^7.5.0"
+ }
+ },
+ "node_modules/@ngrx/entity": {
+ "version": "19.2.0",
+ "resolved": "https://registry.npmjs.org/@ngrx/entity/-/entity-19.2.0.tgz",
+ "integrity": "sha512-JxKFBk0LAHrmCGLQFQeT8mZhwTZPKzq0m0gqCtXgmtzHj9B/ln3yluTtBWgOEf07dDAkEX/q42Sr+kO+5kctvg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "@angular/core": "^19.0.0",
+ "@ngrx/store": "19.2.0",
+ "rxjs": "^6.5.3 || ^7.5.0"
+ }
+ },
+ "node_modules/@ngrx/router-store": {
+ "version": "19.2.0",
+ "resolved": "https://registry.npmjs.org/@ngrx/router-store/-/router-store-19.2.0.tgz",
+ "integrity": "sha512-emR6Y+NIcFxFt1QsyDdMIVhkuGEzawGZM5yOo8A6kUZljzf88S/7tHXQRKLz1Vy2fpDRZDO6r/0eagW0JDMfLA==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "@angular/common": "^19.0.0",
+ "@angular/core": "^19.0.0",
+ "@angular/router": "^19.0.0",
+ "@ngrx/store": "19.2.0",
+ "rxjs": "^6.5.3 || ^7.5.0"
+ }
+ },
+ "node_modules/@ngrx/store": {
+ "version": "19.2.0",
+ "resolved": "https://registry.npmjs.org/@ngrx/store/-/store-19.2.0.tgz",
+ "integrity": "sha512-k2n/jLJZ75Z5rd5vPa2mXPYG/On2rFLiNdrccs9Dw2r+oJosORMlN5TbdsGHhVDFfjzbY9a7JbHUE3YOa69gqw==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "@angular/core": "^19.0.0",
+ "rxjs": "^6.5.3 || ^7.5.0"
+ }
+ },
"node_modules/@ngtools/webpack": {
"version": "19.2.12",
"resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-19.2.12.tgz",
@@ -6235,6 +6347,13 @@
"@types/node": "*"
}
},
+ "node_modules/@types/crypto-js": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz",
+ "integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/eslint": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
@@ -6914,6 +7033,12 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
+ "node_modules/add": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/add/-/add-2.0.6.tgz",
+ "integrity": "sha512-j5QzrmsokwWWp6kUcJQySpbG+xfOBqqKnup3OIk1pz+kB/80SLorZ9V8zHFLO92Lcd+hbvq8bT+zOGoPkmBV0Q==",
+ "license": "MIT"
+ },
"node_modules/adjust-sourcemap-loader": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/adjust-sourcemap-loader/-/adjust-sourcemap-loader-4.0.0.tgz",
@@ -8235,6 +8360,12 @@
"node": ">= 8"
}
},
+ "node_modules/crypto-js": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
+ "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
+ "license": "MIT"
+ },
"node_modules/css-loader": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/css-loader/-/css-loader-7.1.2.tgz",
@@ -12761,7 +12892,6 @@
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
- "dev": true,
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
@@ -12802,7 +12932,6 @@
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.0.tgz",
"integrity": "sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==",
- "dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
@@ -16703,6 +16832,15 @@
}
}
},
+ "@angular/cdk": {
+ "version": "19.2.17",
+ "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-19.2.17.tgz",
+ "integrity": "sha512-3jG33S+5+kqymCRwQlcSEWlY5rYwkKxe0onln+NXxT0/kteR02vWvv1+Li4/QqSr5JvsGHEhAFsZaR9QtOzbdA==",
+ "requires": {
+ "parse5": "^7.1.2",
+ "tslib": "^2.3.0"
+ }
+ },
"@angular/cli": {
"version": "19.2.12",
"resolved": "https://registry.npmjs.org/@angular/cli/-/cli-19.2.12.tgz",
@@ -16849,6 +16987,14 @@
"tslib": "^2.3.0"
}
},
+ "@angular/material": {
+ "version": "19.2.17",
+ "resolved": "https://registry.npmjs.org/@angular/material/-/material-19.2.17.tgz",
+ "integrity": "sha512-IyA+KP+uUj3r9loqGJrj7qAiEBckj7EVIdV0jlYwqWIUyKWeJ3R88GmLPMH2BgtBU3R/WkS2blXDI0yvRhKfww==",
+ "requires": {
+ "tslib": "^2.3.0"
+ }
+ },
"@angular/platform-browser": {
"version": "19.2.10",
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-19.2.10.tgz",
@@ -18844,6 +18990,46 @@
"dev": true,
"optional": true
},
+ "@ngrx/component-store": {
+ "version": "19.2.0",
+ "resolved": "https://registry.npmjs.org/@ngrx/component-store/-/component-store-19.2.0.tgz",
+ "integrity": "sha512-PMeJkikg6+06BrwAs7YKPXWNTWaHpdNZAtzdDcz2ad8Nv3jLhMk8OXHD03ODfNYm2DGy3Glm7ayv2yckjiy/hw==",
+ "requires": {
+ "tslib": "^2.0.0"
+ }
+ },
+ "@ngrx/effects": {
+ "version": "19.2.0",
+ "resolved": "https://registry.npmjs.org/@ngrx/effects/-/effects-19.2.0.tgz",
+ "integrity": "sha512-DIoFdEdSehAMHUNTWIdl94HjhSh1ZRx0Rgtgp1TjHHyjLiS+vbMmDgPjrCkBv5lT/pEaKbHKnYxjY3CQiW2Hsg==",
+ "requires": {
+ "tslib": "^2.0.0"
+ }
+ },
+ "@ngrx/entity": {
+ "version": "19.2.0",
+ "resolved": "https://registry.npmjs.org/@ngrx/entity/-/entity-19.2.0.tgz",
+ "integrity": "sha512-JxKFBk0LAHrmCGLQFQeT8mZhwTZPKzq0m0gqCtXgmtzHj9B/ln3yluTtBWgOEf07dDAkEX/q42Sr+kO+5kctvg==",
+ "requires": {
+ "tslib": "^2.0.0"
+ }
+ },
+ "@ngrx/router-store": {
+ "version": "19.2.0",
+ "resolved": "https://registry.npmjs.org/@ngrx/router-store/-/router-store-19.2.0.tgz",
+ "integrity": "sha512-emR6Y+NIcFxFt1QsyDdMIVhkuGEzawGZM5yOo8A6kUZljzf88S/7tHXQRKLz1Vy2fpDRZDO6r/0eagW0JDMfLA==",
+ "requires": {
+ "tslib": "^2.0.0"
+ }
+ },
+ "@ngrx/store": {
+ "version": "19.2.0",
+ "resolved": "https://registry.npmjs.org/@ngrx/store/-/store-19.2.0.tgz",
+ "integrity": "sha512-k2n/jLJZ75Z5rd5vPa2mXPYG/On2rFLiNdrccs9Dw2r+oJosORMlN5TbdsGHhVDFfjzbY9a7JbHUE3YOa69gqw==",
+ "requires": {
+ "tslib": "^2.0.0"
+ }
+ },
"@ngtools/webpack": {
"version": "19.2.12",
"resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-19.2.12.tgz",
@@ -19579,6 +19765,12 @@
"@types/node": "*"
}
},
+ "@types/crypto-js": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz",
+ "integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==",
+ "dev": true
+ },
"@types/eslint": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
@@ -20106,6 +20298,11 @@
"dev": true,
"requires": {}
},
+ "add": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/add/-/add-2.0.6.tgz",
+ "integrity": "sha512-j5QzrmsokwWWp6kUcJQySpbG+xfOBqqKnup3OIk1pz+kB/80SLorZ9V8zHFLO92Lcd+hbvq8bT+zOGoPkmBV0Q=="
+ },
"adjust-sourcemap-loader": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/adjust-sourcemap-loader/-/adjust-sourcemap-loader-4.0.0.tgz",
@@ -21007,6 +21204,11 @@
}
}
},
+ "crypto-js": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
+ "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q=="
+ },
"css-loader": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/css-loader/-/css-loader-7.1.2.tgz",
@@ -24143,7 +24345,6 @@
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
- "dev": true,
"requires": {
"entities": "^6.0.0"
},
@@ -24151,8 +24352,7 @@
"entities": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.0.tgz",
- "integrity": "sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==",
- "dev": true
+ "integrity": "sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw=="
}
}
},
diff --git a/package.json b/package.json
index 6584917..a0997c9 100644
--- a/package.json
+++ b/package.json
@@ -16,9 +16,18 @@
"@angular/compiler": "^19.2.10",
"@angular/core": "^19.2.10",
"@angular/forms": "^19.2.10",
+ "@angular/material": "^19.2.17",
"@angular/platform-browser": "^19.2.10",
"@angular/platform-browser-dynamic": "^19.2.10",
"@angular/router": "^19.2.10",
+ "@angular/cdk": "^19.2.10",
+ "@ngrx/store": "^19.2.0",
+ "@ngrx/effects": "^19.2.0",
+ "@ngrx/entity": "^19.2.0",
+ "@ngrx/router-store": "^19.2.0",
+ "@ngrx/component-store": "^19.2.0",
+ "add": "^2.0.6",
+ "crypto-js": "^4.2.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
@@ -32,6 +41,7 @@
"@angular-eslint/template-parser": "19.4.0",
"@angular/cli": "^19.2.12",
"@angular/compiler-cli": "^19.2.10",
+ "@types/crypto-js": "^4.2.2",
"@types/jasmine": "~4.3.0",
"@typescript-eslint/eslint-plugin": "8.32.1",
"@typescript-eslint/parser": "8.32.1",
@@ -45,4 +55,4 @@
"prettier": "^3.3.3",
"typescript": "~5.8.3"
}
-}
+}
\ No newline at end of file
diff --git a/src/app/app-routes.ts b/src/app/app-routes.ts
index a1cf04d..88c3f93 100644
--- a/src/app/app-routes.ts
+++ b/src/app/app-routes.ts
@@ -1,3 +1,8 @@
import { Routes } from '@angular/router';
-export const appRoutes: Routes = [];
+export const appRoutes: Routes = [
+ { path: '', redirectTo: '/blog-posts', pathMatch: 'full' },
+ { path: 'blog-posts', loadComponent: () => import('./features/blog-posts/posts-page/posts-page.component').then(m => m.PostsPageComponent) },
+ { path: 'blog-posts/:id', loadComponent: () => import('./features/blog-posts/posts-page/posts-page.component').then(m => m.PostsPageComponent) },
+ { path: 'guest-book', loadComponent: () => import('./features/guest-page/guest-book/guest-book.component').then(m => m.GuestBookComponent) }
+];
\ No newline at end of file
diff --git a/src/app/app.component.html b/src/app/app.component.html
index f28834e..e7a4fa2 100644
--- a/src/app/app.component.html
+++ b/src/app/app.component.html
@@ -1,488 +1,77 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
{{ title }} app is running!
-
-
-
-
-
-
-
Resources
-
Here are some links to help you get started:
-
-
-
-
-
Next Steps
-
What do you want to do next with your app?
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-@switch (selection.value) {
- @default {
-
ng generate component xyz
-}
- @case ('material') {
-
ng add @angular/material
-}
- @case ('pwa') {
-
ng add @angular/pwa
-}
- @case ('dependency') {
-
ng add _____
-}
- @case ('test') {
-
ng test
-}
- @case ('build') {
-
ng build
-}
- }
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
Latest Posts
+
+ @if (loading()) {
+
+ } @else {
+
+ @for (post of posts(); track post.id) {
+
+
+ {{ post.title }}
+
+
+ {{ post.body }}
+
+
+
+ View Comments
+
+
+
+ }
+
+ }
+
+
+
+ @if (currentPost()) {
+
+
+ {{ currentPost()?.title }}
+
+
+ {{ currentPost()?.body }}
+
+
+
+
Comments
+
+ @if (loading()) {
+
+ } @else {
+
+ }
+ }
+
+
+
\ No newline at end of file
diff --git a/src/app/app.component.scss b/src/app/app.component.scss
index e69de29..16b15fe 100644
--- a/src/app/app.component.scss
+++ b/src/app/app.component.scss
@@ -0,0 +1,7 @@
+.menu-container {
+ display: flex;
+ flex-direction: row;
+ gap: 10px;
+ justify-content: center;
+ padding: 10px;
+}
\ No newline at end of file
diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts
index a59d261..3bdb611 100644
--- a/src/app/app.component.spec.ts
+++ b/src/app/app.component.spec.ts
@@ -19,10 +19,10 @@ describe('AppComponent', () => {
expect(app.title).toEqual('angular-template');
});
- it('should render title', () => {
+ it('should contain navigation element', () => {
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
- expect(compiled.querySelector('.content span')?.textContent).toContain('angular-template app is running!');
+ expect(compiled.querySelector('header nav')).toBeTruthy();
});
});
diff --git a/src/app/app.component.ts b/src/app/app.component.ts
index c53dbc9..8a562b8 100644
--- a/src/app/app.component.ts
+++ b/src/app/app.component.ts
@@ -1,13 +1,80 @@
-
-import { Component } from '@angular/core';
-import { RouterOutlet } from '@angular/router';
+import { Component, inject, signal } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { HttpClient, HttpClientModule } from '@angular/common/http';
+import { RouterModule, RouterOutlet, ActivatedRoute } from '@angular/router';
+import { MatCardModule } from '@angular/material/card';
+import { MatButtonModule } from '@angular/material/button';
+import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
+import { MatListModule } from '@angular/material/list';
+import { MatDividerModule } from '@angular/material/divider';
+import {MatMenuModule} from '@angular/material/menu';
@Component({
selector: 'app-root',
- imports: [RouterOutlet],
+ imports: [
+ CommonModule,
+ RouterOutlet,
+ HttpClientModule,
+ RouterModule,
+ MatCardModule,
+ MatButtonModule,
+ MatProgressSpinnerModule,
+ MatListModule,
+ MatDividerModule,
+ MatMenuModule
+ ],
templateUrl: './app.component.html',
- styleUrls: ['./app.component.scss'],
+ styleUrls: ['./app.component.scss']
})
export class AppComponent {
title = 'angular-template';
+ private http = inject(HttpClient);
+ public route = inject(ActivatedRoute);
+
+ posts = signal([]);
+ comments = signal([]);
+ loading = signal(false);
+ currentPost = signal(null);
+
+ constructor() {
+ this.loadPosts();
+
+ this.route.params.subscribe(params => {
+ const postId = params['id'];
+ if (postId) {
+ this.loadComments(postId);
+ }
+ });
+ }
+
+ loadPosts() {
+ this.loading.set(true);
+ this.http.get('https://jsonplaceholder.typicode.com/posts')
+ .subscribe({
+ next: (posts: any) => {
+ this.posts.set(posts);
+ this.loading.set(false);
+ },
+ error: (err) => {
+ console.error(err);
+ this.loading.set(false);
+ }
+ });
+ }
+
+ loadComments(postId: number) {
+ this.loading.set(true);
+ this.currentPost.set(this.posts().find(p => p.id == postId));
+ this.http.get(`https://jsonplaceholder.typicode.com/posts/${postId}/comments`)
+ .subscribe({
+ next: (comments: any) => {
+ this.comments.set(comments);
+ this.loading.set(false);
+ },
+ error: (err) => {
+ console.error(err);
+ this.loading.set(false);
+ }
+ });
+ }
}
diff --git a/src/app/features/blog-posts/models/comment.model.ts b/src/app/features/blog-posts/models/comment.model.ts
new file mode 100644
index 0000000..c71328c
--- /dev/null
+++ b/src/app/features/blog-posts/models/comment.model.ts
@@ -0,0 +1,14 @@
+export interface Post {
+ userId: number;
+ id: number;
+ title: string;
+ body: string;
+}
+
+export interface Comment {
+ postId: number;
+ id: number;
+ name: string;
+ email: string;
+ body: string;
+}
\ No newline at end of file
diff --git a/src/app/features/blog-posts/post-details-page/post-details-page.component.html b/src/app/features/blog-posts/post-details-page/post-details-page.component.html
new file mode 100644
index 0000000..e46af42
--- /dev/null
+++ b/src/app/features/blog-posts/post-details-page/post-details-page.component.html
@@ -0,0 +1,29 @@
+
+ @if (loading()) {
+
+ } @else {
+ @if (post()) {
+
+
+ {{ post()?.title }}
+
+
+ {{ post()?.body }}
+
+
+
+
Comments
+
+
+ }
+ }
+
\ No newline at end of file
diff --git a/src/app/features/blog-posts/post-details-page/post-details-page.component.scss b/src/app/features/blog-posts/post-details-page/post-details-page.component.scss
new file mode 100644
index 0000000..e69de29
diff --git a/src/app/features/blog-posts/post-details-page/post-details-page.component.spec.ts b/src/app/features/blog-posts/post-details-page/post-details-page.component.spec.ts
new file mode 100644
index 0000000..6c072bd
--- /dev/null
+++ b/src/app/features/blog-posts/post-details-page/post-details-page.component.spec.ts
@@ -0,0 +1,34 @@
+import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'
+import { PostDetailsPageComponent } from './post-details-page.component';
+import { provideMockStore } from '@ngrx/store/testing';
+import { provideRouter } from '@angular/router'
+
+describe('PostDetailsPage', () => {
+ let component: PostDetailsPageComponent;
+ let fixture: ComponentFixture;
+
+ const initialState = {
+ posts: {
+ entities: {},
+ ids: [],
+ loading: false,
+ error: null
+ }
+ };
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ providers: [
+ provideMockStore({initialState}),
+ provideRouter([])
+ ]
+ });
+ fixture = TestBed.createComponent(PostDetailsPageComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeDefined();
+ });
+});
\ No newline at end of file
diff --git a/src/app/features/blog-posts/post-details-page/post-details-page.component.ts b/src/app/features/blog-posts/post-details-page/post-details-page.component.ts
new file mode 100644
index 0000000..26f5527
--- /dev/null
+++ b/src/app/features/blog-posts/post-details-page/post-details-page.component.ts
@@ -0,0 +1,60 @@
+import { Component, inject } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { MatCardModule } from '@angular/material/card';
+import { MatListModule } from '@angular/material/list';
+import { MatDividerModule } from '@angular/material/divider';
+import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
+import { ActivatedRoute } from '@angular/router';
+import { Store } from '@ngrx/store';
+import { signal, effect } from '@angular/core';
+
+import * as PostsActions from '../state/posts.actions';
+import { selectPostById, selectComments, selectLoading } from '../state/posts.selectors';
+import { Post, Comment } from '../models/comment.model';
+
+@Component({
+ selector: 'app-post-details-page',
+ imports: [
+ CommonModule,
+ MatCardModule,
+ MatListModule,
+ MatDividerModule,
+ MatProgressSpinnerModule,
+ ],
+ templateUrl: './post-details-page.component.html',
+ styleUrls: ['./post-details-page.component.scss'],
+})
+export class PostDetailsPageComponent {
+ private route = inject(ActivatedRoute);
+ private store = inject(Store);
+
+ post = signal(null);
+ comments = signal([]);
+ loading = signal(false);
+
+ constructor() {
+ const postId = Number(this.route.snapshot.paramMap.get('id'));
+
+ effect(() => {
+ this.store.select(selectPostById(postId)).subscribe((post) => {
+ if (post != undefined) {
+ this.post.set(post);
+ }
+ });
+ });
+
+ effect(() => {
+ this.store.select(selectComments).subscribe((comments) => {
+ this.comments.set(comments);
+ });
+ });
+
+ effect(() => {
+ this.store.select(selectLoading).subscribe((loading) => {
+ this.loading.set(loading);
+ });
+ });
+
+ this.store.dispatch(PostsActions.loadComments({ postId }));
+ }
+}
\ No newline at end of file
diff --git a/src/app/features/blog-posts/posts-page/posts-page.component.html b/src/app/features/blog-posts/posts-page/posts-page.component.html
new file mode 100644
index 0000000..81c0b1a
--- /dev/null
+++ b/src/app/features/blog-posts/posts-page/posts-page.component.html
@@ -0,0 +1,29 @@
+
+
+
+
+
+ @if (loading()) {
+
+ } @else {
+
+ @for (post of posts(); track post.id) {
+
+
+
+ {{ post.title }}
+
+
+ {{ post.body }}
+
+
+
+ View Comments
+
+
+
+
+ }
+
+ }
+
\ No newline at end of file
diff --git a/src/app/features/blog-posts/posts-page/posts-page.component.scss b/src/app/features/blog-posts/posts-page/posts-page.component.scss
new file mode 100644
index 0000000..a1732b5
--- /dev/null
+++ b/src/app/features/blog-posts/posts-page/posts-page.component.scss
@@ -0,0 +1,9 @@
+.header-container{
+ display: flex;
+ flex-direction: row;
+ justify-content: space-around;
+}
+
+.post-card-container {
+ padding: 10px;
+}
\ No newline at end of file
diff --git a/src/app/features/blog-posts/posts-page/posts-page.component.spec.ts b/src/app/features/blog-posts/posts-page/posts-page.component.spec.ts
new file mode 100644
index 0000000..2065d75
--- /dev/null
+++ b/src/app/features/blog-posts/posts-page/posts-page.component.spec.ts
@@ -0,0 +1,32 @@
+import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'
+import { PostsPageComponent } from './posts-page.component';
+import { provideMockStore } from '@ngrx/store/testing';
+
+describe('PostsPageComponent', () => {
+ let component: PostsPageComponent;
+ let fixture: ComponentFixture;
+
+ const initialState = {
+ posts: {
+ entities: {},
+ ids: [],
+ loading: false,
+ error: null
+ }
+ };
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ providers: [
+ provideMockStore({initialState})
+ ]
+ });
+ fixture = TestBed.createComponent(PostsPageComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeDefined();
+ });
+});
\ No newline at end of file
diff --git a/src/app/features/blog-posts/posts-page/posts-page.component.ts b/src/app/features/blog-posts/posts-page/posts-page.component.ts
new file mode 100644
index 0000000..ea22f12
--- /dev/null
+++ b/src/app/features/blog-posts/posts-page/posts-page.component.ts
@@ -0,0 +1,47 @@
+import { Component, inject } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { MatCardModule } from '@angular/material/card';
+import { MatButtonModule } from '@angular/material/button';
+import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
+import { RouterModule } from '@angular/router';
+import { Store } from '@ngrx/store';
+import { signal, effect } from '@angular/core';
+
+import * as PostsActions from '../state/posts.actions';
+import { selectAllPosts, selectLoading } from '../state/posts.selectors';
+import { Post } from '../models/comment.model';
+
+@Component({
+ selector: 'app-posts-page',
+ imports: [
+ CommonModule,
+ MatCardModule,
+ MatButtonModule,
+ MatProgressSpinnerModule,
+ RouterModule,
+ ],
+ templateUrl: './posts-page.component.html',
+ styleUrls: ['./posts-page.component.scss'],
+})
+export class PostsPageComponent {
+ private store = inject(Store);
+
+ posts = signal([]);
+ loading = signal(false);
+
+ constructor() {
+ this.store.dispatch(PostsActions.loadPosts());
+
+ effect(() => {
+ this.store.select(selectAllPosts).subscribe((posts) => {
+ this.posts.set(posts);
+ });
+ });
+
+ effect(() => {
+ this.store.select(selectLoading).subscribe((loading) => {
+ this.loading.set(loading);
+ });
+ });
+ }
+}
\ No newline at end of file
diff --git a/src/app/features/blog-posts/services/posts.service.spec.ts b/src/app/features/blog-posts/services/posts.service.spec.ts
new file mode 100644
index 0000000..e3e2e76
--- /dev/null
+++ b/src/app/features/blog-posts/services/posts.service.spec.ts
@@ -0,0 +1,21 @@
+import { TestBed } from '@angular/core/testing';
+import { PostsService } from './posts.service';
+import { HttpClient } from '@angular/common/http';
+
+describe('PostsService', () => {
+ let service: PostsService;
+ let httpServiceceSpy: jasmine.SpyObj;
+
+ beforeEach(() => {
+ const spy = jasmine.createSpyObj('HttpClient', ['get']);
+
+ TestBed.configureTestingModule({
+ providers: [PostsService, { provide: HttpClient, useValue: spy }]
+ });
+ service = TestBed.inject(PostsService);
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+});
diff --git a/src/app/features/blog-posts/services/posts.service.ts b/src/app/features/blog-posts/services/posts.service.ts
new file mode 100644
index 0000000..7fcacb5
--- /dev/null
+++ b/src/app/features/blog-posts/services/posts.service.ts
@@ -0,0 +1,21 @@
+import { Injectable } from '@angular/core';
+import { HttpClient } from '@angular/common/http';
+import { Observable } from 'rxjs';
+import { Post, Comment } from '../models/comment.model';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class PostsService {
+ private apiUrl = 'https://jsonplaceholder.typicode.com';
+
+ constructor(private http: HttpClient) {}
+
+ getPosts(): Observable {
+ return this.http.get(`${this.apiUrl}/posts`);
+ }
+
+ getComments(postId: number): Observable {
+ return this.http.get(`${this.apiUrl}/posts/${postId}/comments`);
+ }
+}
\ No newline at end of file
diff --git a/src/app/features/blog-posts/state/posts.actions.ts b/src/app/features/blog-posts/state/posts.actions.ts
new file mode 100644
index 0000000..c21942b
--- /dev/null
+++ b/src/app/features/blog-posts/state/posts.actions.ts
@@ -0,0 +1,27 @@
+import { createAction, props } from '@ngrx/store';
+import { Post, Comment } from '../models/comment.model';
+
+// Load Posts
+export const loadPosts = createAction('[Posts] Load Posts');
+export const loadPostsSuccess = createAction(
+ '[Posts] Load Posts Success',
+ props<{ posts: Post[] }>()
+);
+export const loadPostsFailure = createAction(
+ '[Posts] Load Posts Failure',
+ props<{ error: string }>()
+);
+
+// Load Comments
+export const loadComments = createAction(
+ '[Posts] Load Comments',
+ props<{ postId: number }>()
+);
+export const loadCommentsSuccess = createAction(
+ '[Posts] Load Comments Success',
+ props<{ comments: Comment[] }>()
+);
+export const loadCommentsFailure = createAction(
+ '[Posts] Load Comments Failure',
+ props<{ error: string }>()
+);
\ No newline at end of file
diff --git a/src/app/features/blog-posts/state/posts.effects.ts b/src/app/features/blog-posts/state/posts.effects.ts
new file mode 100644
index 0000000..332d0af
--- /dev/null
+++ b/src/app/features/blog-posts/state/posts.effects.ts
@@ -0,0 +1,39 @@
+import { Injectable } from '@angular/core';
+import { Actions, createEffect, ofType } from '@ngrx/effects';
+import { PostsService } from '../services/posts.service';
+import { of } from 'rxjs';
+import { catchError, map, mergeMap } from 'rxjs/operators';
+import * as PostsActions from './posts.actions';
+
+@Injectable()
+export class PostsEffects {
+ loadPosts$ = createEffect(() =>
+ this.actions$.pipe(
+ ofType(PostsActions.loadPosts),
+ mergeMap(() =>
+ this.postsService.getPosts().pipe(
+ map((posts) => PostsActions.loadPostsSuccess({ posts })),
+ catchError((error) =>
+ of(PostsActions.loadPostsFailure({ error: error.message }))
+ )
+ )
+ )
+ )
+ );
+
+ loadComments$ = createEffect(() =>
+ this.actions$.pipe(
+ ofType(PostsActions.loadComments),
+ mergeMap((action) =>
+ this.postsService.getComments(action.postId).pipe(
+ map((comments) => PostsActions.loadCommentsSuccess({ comments })),
+ catchError((error) =>
+ of(PostsActions.loadCommentsFailure({ error: error.message }))
+ )
+ )
+ )
+ )
+ );
+
+ constructor(private actions$: Actions, private postsService: PostsService) {}
+}
\ No newline at end of file
diff --git a/src/app/features/blog-posts/state/posts.reducer.ts b/src/app/features/blog-posts/state/posts.reducer.ts
new file mode 100644
index 0000000..bdb354f
--- /dev/null
+++ b/src/app/features/blog-posts/state/posts.reducer.ts
@@ -0,0 +1,51 @@
+import { createReducer, on } from '@ngrx/store';
+import * as PostsActions from './posts.actions';
+import { Post, Comment } from '../models/comment.model';
+
+export interface PostsState {
+ posts: Post[];
+ comments: Comment[];
+ loading: boolean;
+ error: string | null;
+}
+
+export const initialState: PostsState = {
+ posts: [],
+ comments: [],
+ loading: false,
+ error: null,
+};
+
+export const postsReducer = createReducer(
+ initialState,
+ on(PostsActions.loadPosts, (state) => ({
+ ...state,
+ loading: true,
+ error: null,
+ })),
+ on(PostsActions.loadPostsSuccess, (state, { posts }) => ({
+ ...state,
+ posts,
+ loading: false,
+ })),
+ on(PostsActions.loadPostsFailure, (state, { error }) => ({
+ ...state,
+ loading: false,
+ error,
+ })),
+ on(PostsActions.loadComments, (state) => ({
+ ...state,
+ loading: true,
+ error: null,
+ })),
+ on(PostsActions.loadCommentsSuccess, (state, { comments }) => ({
+ ...state,
+ comments,
+ loading: false,
+ })),
+ on(PostsActions.loadCommentsFailure, (state, { error }) => ({
+ ...state,
+ loading: false,
+ error,
+ }))
+);
\ No newline at end of file
diff --git a/src/app/features/blog-posts/state/posts.selectors.ts b/src/app/features/blog-posts/state/posts.selectors.ts
new file mode 100644
index 0000000..9820c46
--- /dev/null
+++ b/src/app/features/blog-posts/state/posts.selectors.ts
@@ -0,0 +1,29 @@
+import { createFeatureSelector, createSelector } from '@ngrx/store';
+import { PostsState } from './posts.reducer';
+
+export const selectPostsState = createFeatureSelector('posts');
+
+export const selectAllPosts = createSelector(
+ selectPostsState,
+ (state) => state.posts
+);
+
+export const selectPostById = (id: number) =>
+ createSelector(selectAllPosts, (posts) =>
+ posts.find((post) => post.id === id)
+ );
+
+export const selectComments = createSelector(
+ selectPostsState,
+ (state) => state.comments
+);
+
+export const selectLoading = createSelector(
+ selectPostsState,
+ (state) => state.loading
+);
+
+export const selectError = createSelector(
+ selectPostsState,
+ (state) => state.error
+);
\ No newline at end of file
diff --git a/src/app/features/guest-page/author-popup/author-popup.component.html b/src/app/features/guest-page/author-popup/author-popup.component.html
new file mode 100644
index 0000000..21034bd
--- /dev/null
+++ b/src/app/features/guest-page/author-popup/author-popup.component.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/app/features/guest-page/author-popup/author-popup.component.scss b/src/app/features/guest-page/author-popup/author-popup.component.scss
new file mode 100644
index 0000000..5fe279f
--- /dev/null
+++ b/src/app/features/guest-page/author-popup/author-popup.component.scss
@@ -0,0 +1,5 @@
+.button-container {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-around;
+}
\ No newline at end of file
diff --git a/src/app/features/guest-page/author-popup/author-popup.component.spec.ts b/src/app/features/guest-page/author-popup/author-popup.component.spec.ts
new file mode 100644
index 0000000..d11d30c
--- /dev/null
+++ b/src/app/features/guest-page/author-popup/author-popup.component.spec.ts
@@ -0,0 +1,34 @@
+import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'
+import { AuthorPopupComponent } from './author-popup.component';
+import { provideMockStore } from '@ngrx/store/testing';
+import { provideRouter } from '@angular/router'
+
+describe('AuthorPopupComponent', () => {
+ let component: AuthorPopupComponent;
+ let fixture: ComponentFixture;
+
+ const initialState = {
+ posts: {
+ entities: {},
+ ids: [],
+ loading: false,
+ error: null
+ }
+ };
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ providers: [
+ provideMockStore({initialState}),
+ provideRouter([])
+ ]
+ });
+ fixture = TestBed.createComponent(AuthorPopupComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeDefined();
+ });
+});
\ No newline at end of file
diff --git a/src/app/features/guest-page/author-popup/author-popup.component.ts b/src/app/features/guest-page/author-popup/author-popup.component.ts
new file mode 100644
index 0000000..17ed01f
--- /dev/null
+++ b/src/app/features/guest-page/author-popup/author-popup.component.ts
@@ -0,0 +1,48 @@
+import { Component, OnDestroy, OnInit } from '@angular/core';
+import { Store } from '@ngrx/store';
+import { selectSelectedEntry } from '../state/guest-book.selectors';
+import { closeAuthorPopup } from '../state/guest-book.actions';
+import { AvatarService } from '../services/avatar.service';
+import { MatDialogModule } from '@angular/material/dialog';
+import { MatButtonModule } from '@angular/material/button';
+import { CommonModule } from '@angular/common';
+import { MatIconModule } from '@angular/material/icon';
+import { Subscription } from 'rxjs';
+
+@Component({
+ selector: 'app-author-popup',
+ templateUrl: './author-popup.component.html',
+ styleUrls: ['./author-popup.component.scss'],
+ imports: [CommonModule, MatDialogModule, MatButtonModule, MatIconModule],
+})
+export class AuthorPopupComponent implements OnInit, OnDestroy {
+ entry$ = this.store.select(selectSelectedEntry);
+ avatarUrl = '';
+
+ private subscription = new Subscription();
+
+ constructor(
+ private store: Store,
+ private avatarService: AvatarService
+ ) {
+ }
+
+ ngOnInit(): void {
+ if (this.entry$ != undefined) {
+ this.subscription.add(
+ this.entry$.subscribe(entry => {
+ if (entry) {
+ this.avatarUrl = this.avatarService.getGravatarUrl(entry.email);
+ }
+ }));
+ }
+ }
+
+ closePopUp() {
+ this.store.dispatch(closeAuthorPopup());
+ }
+
+ ngOnDestroy(): void {
+ this.subscription.unsubscribe();
+ }
+}
\ No newline at end of file
diff --git a/src/app/features/guest-page/guest-book/guest-book.component.html b/src/app/features/guest-page/guest-book/guest-book.component.html
new file mode 100644
index 0000000..b05d22b
--- /dev/null
+++ b/src/app/features/guest-page/guest-book/guest-book.component.html
@@ -0,0 +1,10 @@
+
+
+ Guest Book
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/app/features/guest-page/guest-book/guest-book.component.ts b/src/app/features/guest-page/guest-book/guest-book.component.ts
new file mode 100644
index 0000000..47240b3
--- /dev/null
+++ b/src/app/features/guest-page/guest-book/guest-book.component.ts
@@ -0,0 +1,22 @@
+import { Component, inject } from '@angular/core';
+import { Store } from '@ngrx/store';
+import { loadEntries } from '../state/guest-book.actions';
+import { selectEntries, selectSelectedEntry } from '../state/guest-book.selectors';
+import { MatCardModule } from '@angular/material/card';
+import { CommonModule } from '@angular/common';
+import { GuestFormComponent } from '../guest-form/guest-form.component';
+import { GuestEntriesComponent } from '../guest-entries/guest-entries.component';
+
+@Component({
+ selector: 'app-guest-book',
+ templateUrl: './guest-book.component.html',
+ imports: [CommonModule, MatCardModule, GuestFormComponent, GuestEntriesComponent],
+})
+export class GuestBookComponent {
+ entries$ = this.store.select(selectEntries);
+ selectedEntry$ = this.store.select(selectSelectedEntry);
+
+ constructor(private store: Store) {
+ this.store.dispatch(loadEntries());
+ }
+}
\ No newline at end of file
diff --git a/src/app/features/guest-page/guest-entries/guest-entries.component.html b/src/app/features/guest-page/guest-entries/guest-entries.component.html
new file mode 100644
index 0000000..20de0cb
--- /dev/null
+++ b/src/app/features/guest-page/guest-entries/guest-entries.component.html
@@ -0,0 +1,19 @@
+
+
+
+ {{entry.name}}
+
+
+
+
+
+ {{entry.message}}
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/app/features/guest-page/guest-entries/guest-entries.component.ts b/src/app/features/guest-page/guest-entries/guest-entries.component.ts
new file mode 100644
index 0000000..8e01ec8
--- /dev/null
+++ b/src/app/features/guest-page/guest-entries/guest-entries.component.ts
@@ -0,0 +1,32 @@
+import { Component, inject } from '@angular/core';
+import { Store } from '@ngrx/store';
+import { selectEntries } from '../state/guest-book.selectors';
+import { showAuthorDetails } from '../state/guest-book.actions';
+import { MatCardModule } from '@angular/material/card';
+import { MatButtonModule } from '@angular/material/button';
+import { MatDividerModule } from '@angular/material/divider';
+import { GuestEntry } from 'src/app/features/guest-page/models/guest-entry.model';
+import { MatIconModule } from '@angular/material/icon';
+import { CommonModule } from '@angular/common';
+import {
+ MatDialog
+} from '@angular/material/dialog';
+import { AuthorPopupComponent } from '../author-popup/author-popup.component';
+
+@Component({
+ selector: 'app-guest-entries',
+ templateUrl: './guest-entries.component.html',
+ imports: [CommonModule, MatCardModule, MatButtonModule, MatDividerModule, MatIconModule],
+})
+export class GuestEntriesComponent {
+ entries$ = this.store.select(selectEntries);
+
+ readonly dialog = inject(MatDialog);
+
+ constructor(private store: Store) { }
+
+ onViewAuthor(entry: GuestEntry) {
+ const dialogRef = this.dialog.open(AuthorPopupComponent);
+ this.store.dispatch(showAuthorDetails({ entry }));
+ }
+}
\ No newline at end of file
diff --git a/src/app/features/guest-page/guest-form/guest-form.component.html b/src/app/features/guest-page/guest-form/guest-form.component.html
new file mode 100644
index 0000000..922accc
--- /dev/null
+++ b/src/app/features/guest-page/guest-form/guest-form.component.html
@@ -0,0 +1,35 @@
+
\ No newline at end of file
diff --git a/src/app/features/guest-page/guest-form/guest-form.component.ts b/src/app/features/guest-page/guest-form/guest-form.component.ts
new file mode 100644
index 0000000..782659d
--- /dev/null
+++ b/src/app/features/guest-page/guest-form/guest-form.component.ts
@@ -0,0 +1,33 @@
+import { Component } from '@angular/core';
+import { FormGroup, FormControl, Validators, ReactiveFormsModule } from '@angular/forms';
+import { MatFormFieldModule } from '@angular/material/form-field';
+import { MatInputModule } from '@angular/material/input';
+import { MatButtonModule } from '@angular/material/button';
+import { Store } from '@ngrx/store';
+import { addEntry } from '../state/guest-book.actions';
+import { GuestEntry } from 'src/app/features/guest-page/models/guest-entry.model';
+import { CommonModule } from '@angular/common';
+
+@Component({
+ selector: 'app-guest-form',
+ templateUrl: './guest-form.component.html',
+ imports: [CommonModule, ReactiveFormsModule, MatFormFieldModule, MatInputModule, MatButtonModule],
+})
+export class GuestFormComponent {
+ guestForm = new FormGroup({
+ name: new FormControl('', [Validators.required]),
+ email: new FormControl('', [Validators.required, Validators.email]),
+ message: new FormControl('', [Validators.required, Validators.minLength(20)]),
+ });
+
+ constructor(private store: Store) {}
+
+ onSubmit() {
+ if (this.guestForm.valid) {
+ this.store.dispatch(addEntry({
+ entryData: this.guestForm.value as GuestEntry
+ }));
+ this.guestForm.reset();
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/app/features/guest-page/models/guest-entry.model.ts b/src/app/features/guest-page/models/guest-entry.model.ts
new file mode 100644
index 0000000..583e303
--- /dev/null
+++ b/src/app/features/guest-page/models/guest-entry.model.ts
@@ -0,0 +1,12 @@
+export interface GuestEntry {
+ name: string;
+ email: string;
+ message: string;
+}
+
+export interface GuestBookState {
+ entries: GuestEntry[];
+ selectedEntry: GuestEntry | null;
+ showPopup: boolean;
+ loading: boolean;
+}
\ No newline at end of file
diff --git a/src/app/features/guest-page/services/avatar.service.ts b/src/app/features/guest-page/services/avatar.service.ts
new file mode 100644
index 0000000..b55d9a6
--- /dev/null
+++ b/src/app/features/guest-page/services/avatar.service.ts
@@ -0,0 +1,14 @@
+import { Injectable } from '@angular/core';
+import * as CryptoJS from 'crypto-js';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class AvatarService {
+ private readonly GRAVATAR_BASE_URL = 'https://www.gravatar.com/avatar/';
+
+ getGravatarUrl(email: string): string {
+ const hash = CryptoJS.MD5(email.toLowerCase()).toString();
+ return `https://www.gravatar.com/avatar/${hash}?s=200`;
+ }
+}
\ No newline at end of file
diff --git a/src/app/features/guest-page/state/guest-book.actions.ts b/src/app/features/guest-page/state/guest-book.actions.ts
new file mode 100644
index 0000000..50d1207
--- /dev/null
+++ b/src/app/features/guest-page/state/guest-book.actions.ts
@@ -0,0 +1,26 @@
+import { createAction, props } from '@ngrx/store';
+import { GuestEntry } from '../models/guest-entry.model';
+
+// Load Entries
+export const loadEntries = createAction('[Guest Book] Load Entries');
+export const loadEntriesSuccess = createAction(
+ '[Guest Book] Load Entries Success',
+ props<{ entries: GuestEntry[] }>()
+);
+
+// Add Entry
+export const addEntry = createAction(
+ '[Guest Book] Add Entry',
+ props<{ entryData: GuestEntry }>()
+);
+export const addEntrySuccess = createAction(
+ '[Guest Book] Add Entry Success',
+ props<{ entry: GuestEntry }>()
+);
+
+// UI Actions
+export const showAuthorDetails = createAction(
+ '[Guest Book] Show Author Details',
+ props<{ entry: GuestEntry }>()
+);
+export const closeAuthorPopup = createAction('[Guest Book] Close Author Popup');
\ No newline at end of file
diff --git a/src/app/features/guest-page/state/guest-book.effects.ts b/src/app/features/guest-page/state/guest-book.effects.ts
new file mode 100644
index 0000000..dadab72
--- /dev/null
+++ b/src/app/features/guest-page/state/guest-book.effects.ts
@@ -0,0 +1,47 @@
+import { Injectable } from '@angular/core';
+import { Actions, createEffect, ofType } from '@ngrx/effects';
+import { of } from 'rxjs';
+import { catchError, map, mergeMap } from 'rxjs/operators';
+import * as GuestBookActions from './guest-book.actions';
+import { GuestEntry } from 'src/app/features/guest-page/models/guest-entry.model';
+
+@Injectable()
+export class GuestBookEffects {
+ loadEntries$ = createEffect(() =>
+ this.actions$.pipe(
+ ofType(GuestBookActions.loadEntries),
+ mergeMap(() => {
+ const entries = this.getEntriesFromLocalStorage();
+ return of(GuestBookActions.loadEntriesSuccess({ entries }));
+ })
+ )
+ );
+
+ addEntry$ = createEffect(() =>
+ this.actions$.pipe(
+ ofType(GuestBookActions.addEntry),
+ map(({ entryData }) => {
+ const entry: GuestEntry = {
+ ...entryData,
+ };
+ localStorage.setItem(entry.name as string, JSON.stringify(entry));
+ return GuestBookActions.addEntrySuccess({ entry });
+ })
+ )
+ );
+
+ private getEntriesFromLocalStorage(): GuestEntry[] {
+ const entries: GuestEntry[] = [];
+ if (localStorage.length > 0) {
+ for (let i = 0; i < localStorage.length; i++) {
+ const key = localStorage.key(i);
+ if (key && localStorage.getItem(key)) {
+ entries.push(JSON.parse(localStorage.getItem(key) as string) as GuestEntry);
+ }
+ }
+ }
+ return entries;
+ }
+
+ constructor(private actions$: Actions) {}
+}
\ No newline at end of file
diff --git a/src/app/features/guest-page/state/guest-book.reducer.ts b/src/app/features/guest-page/state/guest-book.reducer.ts
new file mode 100644
index 0000000..6856ee3
--- /dev/null
+++ b/src/app/features/guest-page/state/guest-book.reducer.ts
@@ -0,0 +1,44 @@
+import { createReducer, on } from '@ngrx/store';
+import * as GuestBookActions from './guest-book.actions';
+import { GuestEntry } from '../models/guest-entry.model';
+
+export interface GuestBookState {
+ entries: GuestEntry[];
+ selectedEntry: GuestEntry | null;
+ showPopup: boolean;
+ loading: boolean;
+}
+
+export const initialState: GuestBookState = {
+ entries: [],
+ selectedEntry: null,
+ showPopup: false,
+ loading: false,
+};
+
+export const guestBookReducer = createReducer(
+ initialState,
+ on(GuestBookActions.loadEntries, (state) => ({
+ ...state,
+ loading: true,
+ })),
+ on(GuestBookActions.loadEntriesSuccess, (state, { entries }) => ({
+ ...state,
+ entries,
+ loading: false,
+ })),
+ on(GuestBookActions.addEntrySuccess, (state, { entry }) => ({
+ ...state,
+ entries: [entry, ...state.entries],
+ })),
+ on(GuestBookActions.showAuthorDetails, (state, { entry }) => ({
+ ...state,
+ selectedEntry: entry,
+ showPopup: true,
+ })),
+ on(GuestBookActions.closeAuthorPopup, (state) => ({
+ ...state,
+ showPopup: false,
+ selectedEntry: null,
+ }))
+);
\ No newline at end of file
diff --git a/src/app/features/guest-page/state/guest-book.selectors.ts b/src/app/features/guest-page/state/guest-book.selectors.ts
new file mode 100644
index 0000000..7d55bec
--- /dev/null
+++ b/src/app/features/guest-page/state/guest-book.selectors.ts
@@ -0,0 +1,19 @@
+import { createFeatureSelector, createSelector } from '@ngrx/store';
+import { GuestBookState } from './guest-book.reducer';
+
+export const selectGuestBookState = createFeatureSelector('guestBook');
+
+export const selectEntries = createSelector(
+ selectGuestBookState,
+ (state) => state.entries
+);
+
+export const selectShowPopup = createSelector(
+ selectGuestBookState,
+ (state) => state.showPopup
+);
+
+export const selectSelectedEntry = createSelector(
+ selectGuestBookState,
+ (state) => state.selectedEntry
+);
\ No newline at end of file
diff --git a/src/main.ts b/src/main.ts
index 75a3f6a..b9e69c8 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -2,8 +2,22 @@ import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter } from '@angular/router';
import { appRoutes } from './app/app-routes';
import { AppComponent } from './app/app.component';
+import { provideHttpClient } from '@angular/common/http';
+import { provideStore } from '@ngrx/store';
+import { provideEffects } from '@ngrx/effects';
+import { provideRouterStore } from '@ngrx/router-store';
+import { postsReducer } from './app/features/blog-posts/state/posts.reducer';
+import { PostsEffects } from './app/features/blog-posts/state/posts.effects';
+import { guestBookReducer } from './app/features/guest-page/state/guest-book.reducer';
+import { GuestBookEffects } from './app/features/guest-page/state/guest-book.effects';
bootstrapApplication(AppComponent, {
- providers: [provideRouter(appRoutes)]
+ providers: [
+ provideHttpClient(),
+ provideRouter(appRoutes),
+ provideStore({ posts: postsReducer, guestBook: guestBookReducer }),
+ provideEffects([PostsEffects, GuestBookEffects]),
+ provideRouterStore()
+ ]
})
.catch(err => console.error(err));
diff --git a/src/styles.scss b/src/styles.scss
index 90d4ee0..fe526f7 100644
--- a/src/styles.scss
+++ b/src/styles.scss
@@ -1 +1,50 @@
-/* You can add global styles to this file, and also import other style files */
+.guest-book-container {
+ max-width: 800px;
+ margin: 2rem auto;
+ padding: 1rem;
+}
+
+.form-container {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ margin-bottom: 2rem;
+}
+
+.entries-container {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.entry-card {
+ margin-bottom: 1rem;
+}
+
+.popup-container {
+ position: relative;
+ padding: 2rem;
+ text-align: center;
+
+ .avatar {
+ width: 100px;
+ height: 100px;
+ border-radius: 50%;
+ margin: 1rem 0;
+ }
+
+ .close-button {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ }
+
+ .author-info {
+ text-align: center;
+
+ mat-icon {
+ vertical-align: middle;
+ margin-right: 8px;
+ }
+ }
+}
\ No newline at end of file