diff --git a/angular.json b/angular.json
index b5db2fb..04bab6a 100644
--- a/angular.json
+++ b/angular.json
@@ -68,7 +68,8 @@
"buildTarget": "angular-template:build:production"
},
"development": {
- "buildTarget": "angular-template:build:development"
+ "buildTarget": "angular-template:build:development",
+ "proxyConfig": "src/proxy.conf.json"
}
},
"defaultConfiguration": "development"
diff --git a/server/index.js b/server/index.js
new file mode 100644
index 0000000..f7889ca
--- /dev/null
+++ b/server/index.js
@@ -0,0 +1,384 @@
+const express = require('express');
+const cors = require('cors');
+const bodyParser = require('body-parser');
+
+const app = express();
+const PORT = 3000;
+
+app.use(cors());
+app.use(bodyParser.json());
+
+// --- Blog Data Model ---
+// {
+// id: number,
+// title: string,
+// blogHtml: string,
+// author: string,
+// date: Date
+// }
+
+let blogs = [
+ {
+ id: 1,
+ title: "Getting Started with Angular",
+ blogHtml: `
Angular is a platform for building mobile and desktop web applications. It provides a way to structure your code and make it maintainable, scalable, and testable. To get started, install the Angular CLI using npm install -g @angular/cli. Then, create a new project with ng new my-app and serve it locally with ng serve. The CLI will scaffold your project structure, including modules, components, and services. Angular uses TypeScript, a superset of JavaScript, which brings strong typing and object-oriented features to your codebase. The framework encourages modular development and supports features like dependency injection, routing, and forms out of the box. As you build your first Angular app, you'll learn about components, templates, and data binding, which are the core building blocks of any Angular application.
+
+ Install Node.js and npm
+ Install Angular CLI
+ Create a new project
+ Serve your app locally
+
+With these steps, you are ready to start exploring Angular's powerful features and build robust web applications.
`,
+ author: "Alice Johnson",
+ date: new Date("2025-05-01")
+ },
+ {
+ id: 2,
+ title: "Understanding Angular Components",
+ blogHtml: `Components are the fundamental building blocks of Angular applications. Each component consists of a TypeScript class, an HTML template, and optional CSS styles. The class contains the logic and data, while the template defines the view. Components are declared using the @Component decorator, which specifies the selector, template, and styles. You can create a new component using the Angular CLI with ng generate component my-component. Components can communicate with each other using input and output properties, allowing for modular and reusable code. Angular's change detection mechanism ensures that the view is always in sync with the underlying data. By organizing your application into components, you can manage complexity and improve maintainability.
+
+ Component class (logic and data)
+ Template (view)
+ Styles (CSS)
+ Input and Output properties
+
+Mastering components is essential for any Angular developer.
`,
+ author: "Bob Smith",
+ date: new Date("2025-05-02")
+ },
+ {
+ id: 3,
+ title: "Angular Directives Explained",
+ blogHtml: `Directives in Angular are classes that add additional behavior to elements in your Angular applications. There are three types of directives: components, attribute directives, and structural directives. Attribute directives change the appearance or behavior of an element, component, or another directive. Structural directives change the DOM layout by adding and removing DOM elements. Common built-in directives include *ngIf for conditional rendering and *ngFor for iterating over lists. You can also create your own custom directives to encapsulate reusable logic. Directives are a powerful way to extend HTML and create dynamic, interactive applications.
+
+ Attribute directives (e.g., ngClass, ngStyle)
+ Structural directives (e.g., *ngIf, *ngFor)
+ Custom directives
+
+Understanding directives is key to building flexible Angular applications.
`,
+ author: "Carol Lee",
+ date: new Date("2025-05-03")
+ },
+ {
+ id: 4,
+ title: "Angular Services and Dependency Injection",
+ blogHtml: `Services in Angular are used to organize and share code across your app. They are typically used for data access, business logic, or shared state. Angular's dependency injection system makes it easy to provide and inject services where needed. To create a service, use the CLI command ng generate service my-service. You can then inject the service into components or other services using the constructor. Services are provided in the root injector by default, making them singletons throughout your app. This pattern promotes code reuse and separation of concerns.
+
+ Create services for shared logic
+ Inject services using constructors
+ Use providedIn: 'root' for singleton services
+
+Dependency injection is a core concept in Angular that enables modular and testable code.
`,
+ author: "David Kim",
+ date: new Date("2025-05-04")
+ },
+ {
+ id: 5,
+ title: "Routing in Angular Applications",
+ blogHtml: `Routing allows you to navigate between different views or components in your Angular application. The Angular Router maps URL paths to components, enabling deep linking and navigation. To set up routing, import RouterModule and define your routes in the app-routing.module.ts. Use the <router-outlet> directive in your template to display routed components. Navigation can be triggered programmatically or using the routerLink directive in templates. Angular's router supports features like route guards, lazy loading, and parameterized routes, making it a powerful tool for building single-page applications.
+
+ Define routes in RouterModule
+ Use <router-outlet> for routed views
+ Navigate with routerLink
+
+Routing is essential for building scalable Angular apps.
`,
+ author: "Eva Brown",
+ date: new Date("2025-05-05")
+ },
+ {
+ id: 6,
+ title: "Angular Forms: Template vs Reactive",
+ blogHtml: `Angular provides two approaches for handling forms: template-driven and reactive forms. Template-driven forms are easy to use and suitable for simple scenarios, while reactive forms offer more control and scalability for complex forms. Template-driven forms use directives in the template, while reactive forms are defined in the component class using FormGroup and FormControl. Both approaches support validation, but reactive forms make it easier to implement custom validators and dynamic form controls. Choose the approach that best fits your application's needs.
+
+ Template-driven forms (ngModel)
+ Reactive forms (FormGroup, FormControl)
+ Validation and custom validators
+
+Understanding both approaches helps you build robust forms in Angular.
`,
+ author: "Frank Green",
+ date: new Date("2025-05-06")
+ },
+ {
+ id: 7,
+ title: "State Management in Angular",
+ blogHtml: `Managing state is crucial for complex Angular applications. Angular provides several options for state management, including services, RxJS, and third-party libraries like NgRx. Services can be used to share state between components, while RxJS observables enable reactive programming. NgRx is a popular library that implements the Redux pattern, providing a single source of truth for your application's state. Choosing the right state management solution depends on your app's complexity and requirements.
+
+ Use services for shared state
+ Leverage RxJS for reactive state
+ Consider NgRx for large-scale apps
+
+Effective state management leads to maintainable and scalable Angular apps.
`,
+ author: "Grace Miller",
+ date: new Date("2025-05-07")
+ },
+ {
+ id: 8,
+ title: "Consuming REST APIs in Angular",
+ blogHtml: `Angular's HttpClient module makes it easy to communicate with RESTful APIs. Import HttpClientModule in your app module and inject HttpClient into your services. Use methods like get, post, put, and delete to interact with your backend. Handle responses using RxJS observables and operators like map and catchError. Always handle errors gracefully and provide feedback to users. Organize your API calls in services to keep your components clean and focused.
+
+ Import HttpClientModule
+ Inject HttpClient in services
+ Use RxJS for response handling
+
+Consuming APIs is a core skill for Angular developers.
`,
+ author: "Henry Wilson",
+ date: new Date("2025-05-08")
+ },
+ {
+ id: 9,
+ title: "Angular Pipes: Transforming Data",
+ blogHtml: `Pipes in Angular are used to transform data in templates. Built-in pipes include DatePipe, UpperCasePipe, LowerCasePipe, and CurrencyPipe. You can also create custom pipes to implement your own data transformations. Pipes are easy to use: simply add the pipe symbol (|) followed by the pipe name in your template. For example, {{ birthday | date:'longDate' }} formats a date. Pipes help keep your templates clean and readable.
+
+ Built-in pipes (date, uppercase, lowercase, currency)
+ Custom pipes
+ Use pipes in templates
+
+Pipes are a powerful feature for formatting and transforming data in Angular.
`,
+ author: "Ivy Martinez",
+ date: new Date("2025-05-09")
+ },
+ {
+ id: 10,
+ title: "Optimizing Angular Performance",
+ blogHtml: `Performance is critical for modern web applications. Angular provides several tools and techniques to optimize performance, including Ahead-of-Time (AOT) compilation, lazy loading, and change detection strategies. Use AOT to compile your app at build time, reducing load times. Implement lazy loading to load modules only when needed. Use OnPush change detection to minimize unnecessary checks. Profile your app with browser developer tools and Angular DevTools to identify bottlenecks. Optimize your code and assets to ensure a smooth user experience.
+
+ Use AOT compilation
+ Implement lazy loading
+ Optimize change detection
+
+Optimizing performance leads to faster and more responsive Angular apps.
`,
+ author: "Jack Lee",
+ date: new Date("2025-05-10")
+ },
+ {
+ id: 11,
+ title: "Unit Testing in Angular",
+ blogHtml: `Testing is an essential part of software development. Angular supports unit testing with Jasmine and Karma. Write tests for your components, services, and pipes to ensure they work as expected. Use the Angular CLI to run tests with ng test. Mock dependencies and use spies to isolate units of code. Aim for high test coverage to catch bugs early and improve code quality. Testing also makes refactoring safer and more efficient.
+
+ Write tests for components and services
+ Use Jasmine and Karma
+ Run tests with Angular CLI
+
+Unit testing is key to building reliable Angular applications.
`,
+ author: "Karen Young",
+ date: new Date("2025-05-11")
+ },
+ {
+ id: 12,
+ title: "Angular Animations",
+ blogHtml: `Animations can greatly enhance the user experience in your Angular applications. Angular provides a powerful animation API based on CSS and JavaScript. Define animations in your component metadata using the animations property. Use triggers, states, and transitions to control animations. Angular animations are built on top of the Web Animations API, providing smooth and performant effects. Use animations to provide feedback, guide users, and make your app more engaging.
+
+ Define animations in component metadata
+ Use triggers, states, and transitions
+ Enhance user experience
+
+Animations make your Angular apps more dynamic and interactive.
`,
+ author: "Leo Turner",
+ date: new Date("2025-05-12")
+ },
+ {
+ id: 13,
+ title: "Lazy Loading Modules in Angular",
+ blogHtml: `Lazy loading is a technique that loads modules only when they are needed, reducing the initial load time of your Angular application. Configure lazy loading in your routing module using the loadChildren property. Organize your app into feature modules and load them on demand. Lazy loading improves performance, especially for large applications with many routes. Monitor your app's bundle size and optimize your code to take full advantage of lazy loading.
+
+ Configure lazy loading in routing
+ Organize app into feature modules
+ Improve performance for large apps
+
+Lazy loading is essential for scalable Angular applications.
`,
+ author: "Mona Scott",
+ date: new Date("2025-05-13")
+ },
+ {
+ id: 14,
+ title: "Angular CLI Tips and Tricks",
+ blogHtml: `The Angular CLI is a powerful tool that streamlines development. Use commands like ng generate to scaffold components, services, and modules. Use ng build --prod to build your app for production. The CLI also supports testing, linting, and deployment. Customize your project with the angular.json configuration file. Explore CLI options to boost your productivity and maintain a clean codebase.
+
+ Use ng generate for scaffolding
+ Build for production with ng build --prod
+ Customize with angular.json
+
+The CLI is an indispensable tool for Angular developers.
`,
+ author: "Nina Adams",
+ date: new Date("2025-05-14")
+ },
+ {
+ id: 15,
+ title: "Deploying Angular Apps",
+ blogHtml: `Deployment is the final step in the development process. Build your Angular app for production using ng build --prod. This command creates an optimized bundle in the dist/ folder. You can deploy your app to various platforms, including Firebase Hosting, Netlify, Vercel, or your own server. Configure environment variables for different deployment targets. Monitor your app after deployment to ensure it runs smoothly and fix any issues that arise. Proper deployment practices ensure your users have a seamless experience.
+
+ Build for production
+ Deploy to popular platforms
+ Monitor and maintain your app
+
+Successful deployment is crucial for delivering value to your users.
`,
+ author: "Oscar Perez",
+ date: new Date("2025-05-15")
+ }
+];
+
+// --- Comments Data Model ---
+// {
+// id: number,
+// name: string,
+// message: string,
+// email: string,
+// date: Date
+// }
+let comments = [
+ {
+ id: 1,
+ blogId: 1,
+ name: "John Doe",
+ message: "Great article on Angular!",
+ email: "john@example.com",
+ date: new Date()
+ },
+ {
+ id: 2,
+ blogId: 1,
+ name: "Jane Smith",
+ message: "Very helpful, thanks!",
+ email: "jane@example.com",
+ date: new Date()
+ },
+ {
+ id: 3,
+ blogId: 2,
+ name: "Alex Brown",
+ message: "I love Angular topics.",
+ email: "alex@example.com",
+ date: new Date()
+ }
+];
+
+let guestComments = [
+ {
+ id: 1,
+ name: "Emily Carter",
+ message: "This blog is fantastic! I learned a lot from the articles.",
+ email: "emily.carter@example.com",
+ date: new Date("2025-05-20")
+ },
+ {
+ id: 2,
+ name: "Michael Lee",
+ message: "Great resource for Angular beginners. Thanks for sharing!",
+ email: "michael.lee@example.com",
+ date: new Date("2025-05-21")
+ },
+ {
+ id: 3,
+ name: "Sophia Turner",
+ message: "I appreciate the detailed explanations and examples.",
+ email: "sophia.turner@example.com",
+ date: new Date("2025-05-22")
+ }
+];
+
+// 1. Get all blogs
+app.get('/api/blogs', (req, res) => {
+ res.json(blogs);
+});
+
+// 2. Get blog by id
+app.get('/api/blogs/:id', (req, res) => {
+ const id = parseInt(req.params.id, 10);
+ const blog = blogs.find(b => b.id === id);
+ if (!blog) {
+ return res.status(404).json({ error: 'Blog not found' });
+ }
+ res.json(blog);
+});
+
+// 3. Add a new blog
+app.post('/api/blogs', (req, res) => {
+ const { title, blogHtml, author, date } = req.body;
+ if (!title || !blogHtml || !author || !date) {
+ return res.status(400).json({ error: 'Missing fields' });
+ }
+ const newBlog = {
+ id: blogs.length ? blogs[blogs.length - 1].id + 1 : 1,
+ title,
+ blogHtml,
+ author,
+ date: new Date(date)
+ };
+ blogs.push(newBlog);
+ res.status(201).json(newBlog);
+});
+
+// 4. Get comments by blogId
+app.get('/api/blogs/:blogId/comments', (req, res) => {
+ const blogId = parseInt(req.params.blogId, 10);
+ const blogComments = comments.filter(c => c.blogId === blogId);
+ res.json(blogComments);
+});
+
+// 5. Add comment by blogId
+app.post('/api/blogs/:blogId/comments', (req, res) => {
+ const blogId = parseInt(req.params.blogId, 10);
+ const { name, message, email, date } = req.body;
+ if (!name || !message || !email || !date) {
+ return res.status(400).json({ error: 'Missing fields' });
+ }
+ const newComment = {
+ id: comments.length ? comments[comments.length - 1].id + 1 : 1,
+ blogId,
+ name,
+ message,
+ email,
+ date: new Date(date)
+ };
+ comments.push(newComment);
+ res.status(201).json(newComment);
+});
+
+// 6. Delete comment by blogId and comment id
+app.delete('/api/blogs/:blogId/comments/:commentId', (req, res) => {
+ const blogId = parseInt(req.params.blogId, 10);
+ const commentId = parseInt(req.params.commentId, 10);
+ const index = comments.findIndex(c => c.blogId === blogId && c.id === commentId);
+ if (index === -1) {
+ return res.status(404).json({ error: 'Comment not found' });
+ }
+ comments.splice(index, 1);
+ res.status(204).send();
+});
+
+// Get all guest comments
+app.get('/api/guest-comments', (req, res) => {
+ res.json(guestComments);
+});
+
+// Add a new guest comment
+app.post('/api/guest-comments', (req, res) => {
+ const { name, message, email, date } = req.body;
+ if (!name || !message || !email || !date) {
+ return res.status(400).json({ error: 'Missing fields' });
+ }
+ const newGuestComment = {
+ id: guestComments.length ? guestComments[guestComments.length - 1].id + 1 : 1,
+ name,
+ message,
+ email,
+ date: new Date(date)
+ };
+ guestComments.push(newGuestComment);
+ res.status(201).json(newGuestComment);
+});
+
+// Delete a guest comment by id
+app.delete('/api/guest-comments/:id', (req, res) => {
+ const id = parseInt(req.params.id, 10);
+ const index = guestComments.findIndex(c => c.id === id);
+ if (index === -1) {
+ return res.status(404).json({ error: 'Guest comment not found' });
+ }
+ guestComments.splice(index, 1);
+ res.status(204).send();
+});
+
+app.listen(PORT, () => {
+ console.log(`Server running on http://localhost:${PORT}`);
+});
\ No newline at end of file
diff --git a/server/package-lock.json b/server/package-lock.json
new file mode 100644
index 0000000..5f005e0
--- /dev/null
+++ b/server/package-lock.json
@@ -0,0 +1,772 @@
+{
+ "name": "server",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "server",
+ "version": "1.0.0",
+ "license": "ISC",
+ "dependencies": {
+ "body-parser": "^2.2.0",
+ "cors": "^2.8.5",
+ "express": "^5.1.0"
+ }
+ },
+ "node_modules/accepts": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
+ "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
+ "dependencies": {
+ "mime-types": "^3.0.0",
+ "negotiator": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/body-parser": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
+ "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==",
+ "dependencies": {
+ "bytes": "^3.1.2",
+ "content-type": "^1.0.5",
+ "debug": "^4.4.0",
+ "http-errors": "^2.0.0",
+ "iconv-lite": "^0.6.3",
+ "on-finished": "^2.4.1",
+ "qs": "^6.14.0",
+ "raw-body": "^3.0.0",
+ "type-is": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz",
+ "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==",
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
+ "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+ "engines": {
+ "node": ">=6.6.0"
+ }
+ },
+ "node_modules/cors": {
+ "version": "2.8.5",
+ "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
+ "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
+ "dependencies": {
+ "object-assign": "^4",
+ "vary": "^1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
+ "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
+ "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
+ "dependencies": {
+ "accepts": "^2.0.0",
+ "body-parser": "^2.2.0",
+ "content-disposition": "^1.0.0",
+ "content-type": "^1.0.5",
+ "cookie": "^0.7.1",
+ "cookie-signature": "^1.2.1",
+ "debug": "^4.4.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "finalhandler": "^2.1.0",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.0",
+ "merge-descriptors": "^2.0.0",
+ "mime-types": "^3.0.0",
+ "on-finished": "^2.4.1",
+ "once": "^1.4.0",
+ "parseurl": "^1.3.3",
+ "proxy-addr": "^2.0.7",
+ "qs": "^6.14.0",
+ "range-parser": "^1.2.1",
+ "router": "^2.2.0",
+ "send": "^1.1.0",
+ "serve-static": "^2.2.0",
+ "statuses": "^2.0.1",
+ "type-is": "^2.0.1",
+ "vary": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/finalhandler": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz",
+ "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "on-finished": "^2.4.1",
+ "parseurl": "^1.3.3",
+ "statuses": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
+ "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
+ "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
+ "dependencies": {
+ "depd": "2.0.0",
+ "inherits": "2.0.4",
+ "setprototypeof": "1.2.0",
+ "statuses": "2.0.1",
+ "toidentifier": "1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/is-promise": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
+ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
+ "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
+ "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.54.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
+ "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
+ },
+ "node_modules/negotiator": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
+ "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "8.2.0",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz",
+ "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==",
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.14.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
+ "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
+ "dependencies": {
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz",
+ "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==",
+ "dependencies": {
+ "bytes": "3.1.2",
+ "http-errors": "2.0.0",
+ "iconv-lite": "0.6.3",
+ "unpipe": "1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/router": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
+ "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "is-promise": "^4.0.0",
+ "parseurl": "^1.3.3",
+ "path-to-regexp": "^8.0.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ]
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
+ },
+ "node_modules/send": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz",
+ "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==",
+ "dependencies": {
+ "debug": "^4.3.5",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.0",
+ "mime-types": "^3.0.1",
+ "ms": "^2.1.3",
+ "on-finished": "^2.4.1",
+ "range-parser": "^1.2.1",
+ "statuses": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/serve-static": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz",
+ "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==",
+ "dependencies": {
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "parseurl": "^1.3.3",
+ "send": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3",
+ "side-channel-list": "^1.0.0",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
+ "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
+ "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
+ "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
+ "dependencies": {
+ "content-type": "^1.0.5",
+ "media-typer": "^1.1.0",
+ "mime-types": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
+ }
+ }
+}
diff --git a/server/package.json b/server/package.json
new file mode 100644
index 0000000..7d08c62
--- /dev/null
+++ b/server/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "server",
+ "version": "1.0.0",
+ "description": "",
+ "main": "index.js",
+ "scripts": {
+ "test": "echo \"Error: no test specified\" && exit 1"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC",
+ "dependencies": {
+ "body-parser": "^2.2.0",
+ "cors": "^2.8.5",
+ "express": "^5.1.0"
+ }
+}
diff --git a/src/app/app-routes.ts b/src/app/app-routes.ts
index a1cf04d..8b22a45 100644
--- a/src/app/app-routes.ts
+++ b/src/app/app-routes.ts
@@ -1,3 +1,17 @@
import { Routes } from '@angular/router';
+import { BlogListComponent } from './blog-list/blog-list.component';
+import { GuestBookComponent } from './guest-book/guest-book.component';
+import { BlogCommentComponent } from './blog-comment/blog-comment.component';
+import { RegisterComponent } from './register/register.component';
+import { NotFoundComponent } from './not-found/not-found.component';
+import { LoginComponent } from './login/login.component';
-export const appRoutes: Routes = [];
+export const appRoutes: Routes = [
+ { path: 'blogs', component: BlogListComponent, title: "Home - List of blogs" },
+ { path: 'blogs/:id', component: BlogCommentComponent, title: "Blog Details" },
+ { path: 'guest-book', component: GuestBookComponent, title: "GuestBook - Welcome to guest book" },
+ { path: 'register', component: RegisterComponent, title: "Register - welcome" },
+ { path: 'login', component: LoginComponent, title: "Login - welcome" },
+ { path: '', redirectTo: '/blogs', pathMatch: 'full' },
+ { path: '**', component: NotFoundComponent, title: "Page Not Found" }
+];
diff --git a/src/app/app.component.html b/src/app/app.component.html
index f28834e..242b1c6 100644
--- a/src/app/app.component.html
+++ b/src/app/app.component.html
@@ -1,488 +1,2 @@
-
-
-
-
-
-
-
-
-
-
-
- Rocket Ship
-
-
-
-
-
-
-
-
-
-
{{ title }} app is running!
-
-
- Rocket Ship Smoke
-
-
-
-
-
-
-
Resources
-
Here are some links to help you get started:
-
-
-
-
-
Next Steps
-
What do you want to do next with your app?
-
-
-
-
-
-
- New Component
-
-
-
-
- Angular Material
-
-
-
-
- Add PWA Support
-
-
-
-
- Add Dependency
-
-
-
-
- Run and Watch Tests
-
-
-
-
- Build for Production
-
-
-
-
-
-@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
-}
- }
-
-
-
-
-
-
-
-
-
- Gray Clouds Background
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
\ No newline at end of file
diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts
index a59d261..229023e 100644
--- a/src/app/app.component.spec.ts
+++ b/src/app/app.component.spec.ts
@@ -18,11 +18,4 @@ describe('AppComponent', () => {
const app = fixture.componentInstance;
expect(app.title).toEqual('angular-template');
});
-
- it('should render title', () => {
- 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!');
- });
});
diff --git a/src/app/app.component.ts b/src/app/app.component.ts
index c53dbc9..09857c1 100644
--- a/src/app/app.component.ts
+++ b/src/app/app.component.ts
@@ -1,10 +1,14 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
+import { GuestBookComponent } from './guest-book/guest-book.component';
+import { FormsModule } from '@angular/forms';
+import { SiteHeaderComponent } from "./site-header/site-header.component";
@Component({
selector: 'app-root',
- imports: [RouterOutlet],
+ standalone: true,
+ imports: [RouterOutlet, GuestBookComponent, FormsModule, SiteHeaderComponent],
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss'],
})
diff --git a/src/app/blog-comment/blog-comment.component.html b/src/app/blog-comment/blog-comment.component.html
new file mode 100644
index 0000000..90ac70a
--- /dev/null
+++ b/src/app/blog-comment/blog-comment.component.html
@@ -0,0 +1,129 @@
+
\ No newline at end of file
diff --git a/src/app/blog-comment/blog-comment.component.scss b/src/app/blog-comment/blog-comment.component.scss
new file mode 100644
index 0000000..7d90532
--- /dev/null
+++ b/src/app/blog-comment/blog-comment.component.scss
@@ -0,0 +1,163 @@
+@use "../../styles/colors.scss" as colors;
+@use "../../styles/fonts.scss" as fonts;
+@use "../../styles/spacing.scss" as spacing;
+
+.blog-comment-container {
+ max-width: 700px;
+ margin: spacing.$margin-lg auto;
+ background: colors.$color-bg-main;
+ border-radius: spacing.$border-radius-lg;
+ box-shadow: 0 4px 24px colors.$color-shadow;
+ padding: spacing.$padding-lg spacing.$padding-lg * 1.25;
+ font-family: fonts.$font-main;
+ color: colors.$color-text-main;
+}
+
+.blog-details h2 {
+ margin-bottom: 0.2rem;
+ color: #8f94fb;
+}
+
+.blog-meta {
+ color: #e0e0e0;
+ font-size: 0.95rem;
+ margin-bottom: 1.2rem;
+ display: flex;
+ gap: 1.5rem;
+}
+
+.blog-content {
+ margin-bottom: 2rem;
+ line-height: 1.7;
+ color: #fff;
+}
+
+.comments-section {
+ margin-bottom: 2rem;
+ color: #fff;
+}
+
+.comments-section h3,
+.add-comment-section h3 {
+ margin-bottom: 1rem;
+ color: #8f94fb;
+}
+
+.no-comments {
+ color: #ccc;
+ font-style: italic;
+ margin-bottom: 1rem;
+}
+
+ul {
+ list-style: none;
+ padding: 0;
+}
+
+.comment {
+ background: #23262f;
+ border-radius: 8px;
+ padding: 1rem;
+ margin-bottom: 1rem;
+ box-shadow: 0 1px 4px rgba(78, 84, 200, 0.05);
+ color: #fff;
+}
+
+.comment-header {
+ display: flex;
+ justify-content: space-between;
+ font-size: 0.95rem;
+ margin-bottom: 0.5rem;
+ color: #8f94fb;
+}
+
+.comment-message {
+ color: #fff;
+}
+
+.input,
+.textarea {
+ width: 100%;
+ padding: 0.7rem;
+ margin-bottom: 0.8rem;
+ border: 1px solid #353945;
+ border-radius: 6px;
+ font-size: 1rem;
+ background: #23262f;
+ color: #fff;
+ transition: border 0.2s;
+}
+
+.input:focus,
+.textarea:focus {
+ border-color: #4e54c8;
+ outline: none;
+}
+
+.submit-btn {
+ background: linear-gradient(90deg, #8f94fb 0%, #4e54c8 100%);
+ color: #fff;
+ border: none;
+ border-radius: 6px;
+ padding: 0.7rem 1.5rem;
+ font-size: 1rem;
+ cursor: pointer;
+ transition: background 0.2s, box-shadow 0.2s;
+ box-shadow: 0 2px 8px rgba(78, 84, 200, 0.08);
+}
+
+.submit-btn:disabled {
+ background: #bfc2e6;
+ cursor: not-allowed;
+}
+.validation-message {
+ color: #ff6b6b;
+ font-size: 0.95rem;
+ margin-bottom: 0.5rem;
+ margin-top: -0.5rem;
+}
+.error-message {
+ color: #ff6b6b;
+ background: #23262f;
+ border-radius: 6px;
+ padding: 1rem;
+ margin: 1rem 0;
+ font-weight: bold;
+ text-align: center;
+}
+
+.rating-group {
+ margin: 1rem 0 0.5rem 0;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+}
+.rating-label {
+ margin-bottom: 0.3rem;
+ color: #8f94fb;
+ font-weight: 500;
+}
+.stars {
+ display: flex;
+ flex-direction: row-reverse;
+ gap: 0.2rem;
+}
+.star-input {
+ display: none;
+}
+.star {
+ font-size: 2rem;
+ color: #bfc2e6;
+ cursor: pointer;
+ transition: color 0.2s;
+ user-select: none;
+}
+.star.filled,
+.star:hover,
+.star:hover ~ .star {
+ color: #ffd700;
+}
+.star-input:checked ~ label.star,
+.star-input:checked ~ label.star ~ label.star {
+ color: #ffd700;
+}
\ No newline at end of file
diff --git a/src/app/blog-comment/blog-comment.component.spec.ts b/src/app/blog-comment/blog-comment.component.spec.ts
new file mode 100644
index 0000000..f95c009
--- /dev/null
+++ b/src/app/blog-comment/blog-comment.component.spec.ts
@@ -0,0 +1,77 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ActivatedRoute } from '@angular/router';
+import { of } from 'rxjs';
+
+import { BlogCommentComponent } from './blog-comment.component';
+import { BlogService } from './blog.service';
+
+describe('BlogCommentComponent (integration)', () => {
+ let component: BlogCommentComponent;
+ let fixture: ComponentFixture;
+ let blogService: BlogService;
+
+ const mockBlog = {
+ id: 1,
+ title: 'Test Blog',
+ blogHtml: 'Test Content
',
+ date: new Date(),
+ author: 'Tester'
+ };
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [HttpClientTestingModule, BlogCommentComponent],
+ providers: [
+ {
+ provide: ActivatedRoute,
+ useValue: {
+ paramMap: of({ get: () => '1' })
+ }
+ },
+ {
+ provide: BlogService,
+ useValue: {
+ getBlog: () => of(mockBlog),
+ getComments: () => of([]),
+ postComment: () => of({})
+ }
+ }
+ ]
+ })
+ .compileComponents();
+
+ fixture = TestBed.createComponent(BlogCommentComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should render blog title, author, and content', () => {
+ fixture.detectChanges();
+ const compiled = fixture.nativeElement as HTMLElement;
+ expect(compiled.querySelector('.blog-details h2')?.textContent).toContain('Test Blog');
+ expect(compiled.querySelector('.blog-meta')?.textContent).toContain('Tester');
+ expect(compiled.querySelector('.blog-content')?.innerHTML).toContain('Test Content');
+ });
+
+ it('should show "No comments yet" when there are no comments', () => {
+ fixture.detectChanges();
+ const compiled = fixture.nativeElement as HTMLElement;
+ expect(compiled.querySelector('.no-comments')?.textContent).toContain('No comments yet');
+ });
+
+ it('should show validation message if name is empty and touched', () => {
+ fixture.detectChanges();
+ const compiled = fixture.nativeElement as HTMLElement;
+ const nameInput: HTMLInputElement = compiled.querySelector('input[name="name"]')!;
+ nameInput.value = '';
+ nameInput.dispatchEvent(new Event('input'));
+ nameInput.dispatchEvent(new Event('blur'));
+ fixture.detectChanges();
+ expect(compiled.querySelector('.validation-message')?.textContent).toContain('Name is required');
+ });
+});
diff --git a/src/app/blog-comment/blog-comment.component.ts b/src/app/blog-comment/blog-comment.component.ts
new file mode 100644
index 0000000..05b0ff0
--- /dev/null
+++ b/src/app/blog-comment/blog-comment.component.ts
@@ -0,0 +1,93 @@
+import { Component, signal } from '@angular/core';
+import { IBlog } from '../blog-list/blog.model';
+import { IComment } from './comment.model';
+import { ActivatedRoute } from '@angular/router';
+import { BlogService } from './blog.service';
+import { CommonModule } from '@angular/common';
+import { FormsModule, NgForm } from '@angular/forms';
+import { map, switchMap, combineLatest, catchError } from 'rxjs';
+import { toSignal, toObservable } from '@angular/core/rxjs-interop';
+import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
+
+@Component({
+ selector: 'app-blog-comment',
+ imports: [CommonModule, FormsModule],
+ templateUrl: './blog-comment.component.html',
+ styleUrl: './blog-comment.component.scss'
+})
+export class BlogCommentComponent {
+ readonly blogIdSignal = toSignal(
+ this.route.paramMap.pipe(map(params => Number(params.get('id')))),
+ { initialValue: 0 }
+ );
+
+ private blogId$ = this.route.paramMap.pipe(map(params => Number(params.get('id'))));
+
+ private refreshComments = signal(0);
+
+ errorMessage: string | null = null;
+
+ readonly blogSignal = toSignal(
+ this.route.paramMap.pipe(
+ switchMap(params =>
+ this.blogService.getBlog(Number(params.get('id'))).pipe(
+ catchError(() => {
+ this.errorMessage = 'Failed to load blog. Redirecting...';
+ setTimeout(() => {
+ this.errorMessage = null;
+ window.location.href = '/blogs';
+ }, 2000);
+ // Return an empty blog object to keep types happy
+ return [
+ { id: 0, title: '', blogHtml: '', date: new Date(), author: '' } as IBlog
+ ];
+ })
+ )
+ )
+ ),
+ { initialValue: { id: 0, title: '', blogHtml: '', date: new Date(), author: '' } as IBlog }
+ );
+
+ private comments$ = combineLatest([
+ this.blogId$,
+ toObservable(this.refreshComments)
+ ]).pipe(
+ switchMap(([blogId]) => this.blogService.getComments(blogId))
+ );
+
+ readonly commentsSignal = toSignal(this.comments$, { initialValue: [] as IComment[] });
+
+ newComment: IComment = {
+ id: 0,
+ name: '',
+ email: '',
+ message: '',
+ date: new Date(),
+ rating: undefined,
+ };
+
+
+
+ constructor(
+ private route: ActivatedRoute,
+ private blogService: BlogService,
+ private sanitizer: DomSanitizer) {}
+
+ addComment(form: NgForm) {
+ if (this.newComment.name && this.newComment.email && this.newComment.message) {
+ this.newComment.date = new Date();
+ this.blogService.postComment(this.blogIdSignal(), this.newComment).subscribe(() => {
+ this.refreshComments.update(v => v + 1); // trigger refresh
+ this.newComment = { id: 0, name: '', email: '', message: '', date: new Date() };
+ form.resetForm();
+ });
+ } else {
+ alert('Please fill in all fields.');
+ }
+ }
+
+
+ sanitizeHtml(html: string): SafeHtml {
+ return this.sanitizer.bypassSecurityTrustHtml(html);
+ }
+}
diff --git a/src/app/blog-comment/blog.model.ts b/src/app/blog-comment/blog.model.ts
new file mode 100644
index 0000000..fab51ce
--- /dev/null
+++ b/src/app/blog-comment/blog.model.ts
@@ -0,0 +1,7 @@
+export interface IBlog {
+ id: number,
+ title: string,
+ blogHtml: string,
+ author: string,
+ date: Date
+ }
\ No newline at end of file
diff --git a/src/app/blog-comment/blog.service.spec.ts b/src/app/blog-comment/blog.service.spec.ts
new file mode 100644
index 0000000..cc8822b
--- /dev/null
+++ b/src/app/blog-comment/blog.service.spec.ts
@@ -0,0 +1,59 @@
+import { TestBed } from '@angular/core/testing';
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+
+import { BlogService } from './blog.service';
+import { IBlog } from './blog.model';
+import { IComment } from './comment.model';
+
+describe('BlogService', () => {
+ let service: BlogService;
+ let httpMock: HttpTestingController;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [HttpClientTestingModule]
+ });
+ service = TestBed.inject(BlogService);
+ httpMock = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpMock.verify();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should make a GET request to fetch a blog by id', () => {
+ const mockBlog: IBlog = {
+ id: 1,
+ title: 'Test Blog',
+ blogHtml: 'Test
',
+ date: new Date(),
+ author: 'Tester'
+ };
+
+ service.getBlog(1).subscribe(blog => {
+ expect(blog).toEqual(mockBlog);
+ });
+
+ const req = httpMock.expectOne('/api/blogs/1');
+ expect(req.request.method).toBe('GET');
+ req.flush(mockBlog);
+ });
+
+ it('should make a Get request to fetch all comments by blog id', () => {
+ const mockComments: IComment[] = [
+ { id: 1, message: 'Great post!', name: 'User1', email: 'test1@test1', date: new Date() },
+ { id: 2, message: 'Thanks for sharing!', name: 'User2', email: 'test2@test2', date: new Date() }
+ ];
+ service.getComments(1).subscribe(comments => {
+ expect(comments).toEqual(mockComments);
+ });
+
+ const req = httpMock.expectOne('/api/blogs/1/comments');
+ expect(req.request.method).toBe('GET');
+ req.flush(mockComments);
+ });
+});
diff --git a/src/app/blog-comment/blog.service.ts b/src/app/blog-comment/blog.service.ts
new file mode 100644
index 0000000..b852a1d
--- /dev/null
+++ b/src/app/blog-comment/blog.service.ts
@@ -0,0 +1,37 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+import { IBlog } from './blog.model';
+import { Observable, of } from 'rxjs';
+import { IComment } from './comment.model';
+import { tap, shareReplay } from 'rxjs/operators';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class BlogService {
+ private blogCache = new Map>();
+
+ constructor(private http: HttpClient) { }
+
+ getBlog(id: number): Observable {
+ if (this.blogCache.has(id)) {
+ console.log(`BlogService: Returning blog ${id} from cache`);
+ return this.blogCache.get(id)!;
+ }
+ console.log(`BlogService: Fetching blog ${id} from HTTP`);
+ const blog$ = this.http.get(`/api/blogs/${id}`).pipe(
+ shareReplay(1)
+ );
+ this.blogCache.set(id, blog$);
+ return blog$;
+ }
+
+ getComments(blogId: number): Observable {
+ return this.http.get(`/api/blogs/${blogId}/comments`);
+ }
+
+ postComment(blogId: number, comment: IComment): Observable {
+ console.log('Posting comment:', comment);
+ return this.http.post(`/api/blogs/${blogId}/comments`, comment);
+ }
+}
diff --git a/src/app/blog-comment/comment.model.ts b/src/app/blog-comment/comment.model.ts
new file mode 100644
index 0000000..d1cce86
--- /dev/null
+++ b/src/app/blog-comment/comment.model.ts
@@ -0,0 +1,8 @@
+export interface IComment {
+ id: number;
+ name: string;
+ message: string;
+ email: string;
+ date: Date;
+ rating?: number;
+ }
\ No newline at end of file
diff --git a/src/app/blog-list/blog-list.component.html b/src/app/blog-list/blog-list.component.html
new file mode 100644
index 0000000..f18a1fa
--- /dev/null
+++ b/src/app/blog-list/blog-list.component.html
@@ -0,0 +1,25 @@
+
+
+ Order by date:
+
+ Newest First
+ Oldest First
+
+
+
+
+
+
+ {{ blog.title }}
+
+ Published: {{ blog.date | date:'mediumDate' }}
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/app/blog-list/blog-list.component.scss b/src/app/blog-list/blog-list.component.scss
new file mode 100644
index 0000000..40954b5
--- /dev/null
+++ b/src/app/blog-list/blog-list.component.scss
@@ -0,0 +1,110 @@
+.blog-list {
+ background: #181a20;
+ padding: 2rem;
+ border-radius: 12px;
+}
+.blog-list h2 {
+ color: #fff;
+}
+.blog-list-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 1.5rem;
+ padding: 0;
+ list-style: none;
+}
+.blog-item-container {
+ margin: 0;
+}
+.blog-item {
+ width: 100%;
+ text-align: left;
+ background: #23262f;
+ border: none;
+ border-radius: 8px;
+ padding: 1rem;
+ box-shadow: 0 1px 4px rgba(30, 34, 60, 0.15);
+ cursor: pointer;
+ transition: box-shadow 0.2s, background 0.2s;
+ color: #f1f1f1;
+}
+.blog-item:hover {
+ box-shadow: 0 4px 16px rgba(78, 84, 200, 0.18);
+ background: #2a2d3a;
+}
+.blog-item h3 {
+ color: #8f94fb;
+}
+.blog-item small {
+ color: #bfc2e6;
+}
+.bright-pagination {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 1.5rem;
+ margin: 2rem 0 0 0;
+ padding: 1rem 0;
+}
+
+.bright-pagination button {
+ background: linear-gradient(90deg, #8f94fb 0%, #4e54c8 100%);
+ color: #fff;
+ border: none;
+ border-radius: 6px;
+ padding: 0.6rem 1.4rem;
+ font-size: 1rem;
+ font-weight: bold;
+ cursor: pointer;
+ box-shadow: 0 2px 8px rgba(78, 84, 200, 0.15);
+ transition: background 0.2s, box-shadow 0.2s;
+}
+
+.bright-pagination button:disabled {
+ background: #bfc2e6;
+ color: #eee;
+ cursor: not-allowed;
+ box-shadow: none;
+}
+
+.bright-pagination span {
+ color: #4e54c8;
+ font-weight: bold;
+ font-size: 1.1rem;
+ background: #fff;
+ padding: 0.4rem 1rem;
+ border-radius: 6px;
+ box-shadow: 0 1px 4px rgba(78, 84, 200, 0.08);
+}
+.pretty-sort-panel {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ margin-bottom: 2rem;
+ background: #23262f;
+ padding: 1rem 1.5rem;
+ border-radius: 8px;
+ box-shadow: 0 2px 8px rgba(78, 84, 200, 0.10);
+}
+
+.sort-label {
+ color: #8f94fb;
+ font-weight: bold;
+ font-size: 1.05rem;
+ margin-right: 0.5rem;
+}
+
+.sort-select {
+ padding: 0.5rem 1.2rem;
+ border-radius: 6px;
+ border: 1px solid #353945;
+ background: #181a20;
+ color: #f1f1f1;
+ font-size: 1rem;
+ transition: border 0.2s;
+}
+
+.sort-select:focus {
+ border-color: #8f94fb;
+ outline: none;
+}
\ No newline at end of file
diff --git a/src/app/blog-list/blog-list.component.spec.ts b/src/app/blog-list/blog-list.component.spec.ts
new file mode 100644
index 0000000..b5b91ed
--- /dev/null
+++ b/src/app/blog-list/blog-list.component.spec.ts
@@ -0,0 +1,24 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { HttpClientTestingModule } from '@angular/common/http/testing'; // <-- Add this import
+
+import { BlogListComponent } from './blog-list.component';
+
+describe('BlogListComponent', () => {
+ let component: BlogListComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [HttpClientTestingModule, BlogListComponent]
+ })
+ .compileComponents();
+
+ fixture = TestBed.createComponent(BlogListComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/app/blog-list/blog-list.component.ts b/src/app/blog-list/blog-list.component.ts
new file mode 100644
index 0000000..dc14991
--- /dev/null
+++ b/src/app/blog-list/blog-list.component.ts
@@ -0,0 +1,49 @@
+import { Component, computed, effect, signal } from '@angular/core';
+import { BlogService } from './blog.service';
+import { IBlog } from './blog.model';
+import { CommonModule } from '@angular/common';
+import { Router } from '@angular/router';
+import { toSignal } from '@angular/core/rxjs-interop';
+import { FormsModule } from '@angular/forms';
+
+@Component({
+ selector: 'app-blog-list',
+ imports: [CommonModule, FormsModule],
+ standalone: true,
+ templateUrl: './blog-list.component.html',
+ styleUrl: './blog-list.component.scss'
+})
+export class BlogListComponent {
+ readonly pageSize = 6;
+ readonly blogsSignal = toSignal(this.blogService.blogs$, { initialValue: [] as IBlog[] });
+
+ currentPage = signal(1);
+
+ orderBy = signal<'asc' | 'desc'>(
+ (localStorage.getItem('blogOrderBy') as 'asc' | 'desc') || 'desc'
+ );
+
+ readonly persistOrderBy = effect(() => {
+ localStorage.setItem('blogOrderBy', this.orderBy());
+ });
+
+ sortedBlogs = computed(() => {
+ const blogs = this.blogsSignal().slice();
+ return blogs.sort((a, b) => {
+ const dateA = new Date(a.date).getTime();
+ const dateB = new Date(b.date).getTime();
+ return this.orderBy() === 'asc' ? dateA - dateB : dateB - dateA;
+ });
+ });
+
+ readonly totalPages = computed(() =>
+ Math.ceil(this.blogsSignal().length / this.pageSize) || 1
+ );
+
+ constructor(private blogService: BlogService, private router: Router) {}
+
+ onBlogSelected(blog: IBlog) {
+ this.router.navigate(['/blogs', blog.id], { state: { blog } });
+ }
+
+}
diff --git a/src/app/blog-list/blog.model.ts b/src/app/blog-list/blog.model.ts
new file mode 100644
index 0000000..fab51ce
--- /dev/null
+++ b/src/app/blog-list/blog.model.ts
@@ -0,0 +1,7 @@
+export interface IBlog {
+ id: number,
+ title: string,
+ blogHtml: string,
+ author: string,
+ date: Date
+ }
\ No newline at end of file
diff --git a/src/app/blog-list/blog.service.spec.ts b/src/app/blog-list/blog.service.spec.ts
new file mode 100644
index 0000000..bc0a1fe
--- /dev/null
+++ b/src/app/blog-list/blog.service.spec.ts
@@ -0,0 +1,19 @@
+import { TestBed } from '@angular/core/testing';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { BlogService } from './blog.service';
+
+describe('BlogService', () => {
+ let service: BlogService;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [HttpClientTestingModule],
+ providers: [BlogService]
+ });
+ service = TestBed.inject(BlogService);
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+});
diff --git a/src/app/blog-list/blog.service.ts b/src/app/blog-list/blog.service.ts
new file mode 100644
index 0000000..198642a
--- /dev/null
+++ b/src/app/blog-list/blog.service.ts
@@ -0,0 +1,13 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+import { Observable } from 'rxjs';
+import { IBlog } from './blog.model';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class BlogService {
+ readonly blogs$ = this.http.get('/api/blogs');
+
+ constructor(private http: HttpClient) { }
+}
diff --git a/src/app/guest-book/comment-panel/comment/comment.component.html b/src/app/guest-book/comment-panel/comment/comment.component.html
new file mode 100644
index 0000000..bf6a0c9
--- /dev/null
+++ b/src/app/guest-book/comment-panel/comment/comment.component.html
@@ -0,0 +1,60 @@
+
+
+
+
+ {{ entry.name }} ({{ entry.email }})
+ {{ entry.message }}
+
+
+
+
+
+
diff --git a/src/app/guest-book/comment-panel/comment/comment.component.scss b/src/app/guest-book/comment-panel/comment/comment.component.scss
new file mode 100644
index 0000000..9359977
--- /dev/null
+++ b/src/app/guest-book/comment-panel/comment/comment.component.scss
@@ -0,0 +1,111 @@
+.guest-book {
+ max-width: 700px;
+ margin: 2rem auto;
+ background: #181a20;
+ border-radius: 14px;
+ box-shadow: 0 4px 24px rgba(78, 84, 200, 0.10);
+ padding: 2rem 2.5rem;
+ font-family: 'Segoe UI', Arial, sans-serif;
+ color: #f1f1f1;
+}
+.guest-book h2 {
+ color: #8f94fb;
+}
+.entries {
+ list-style: none;
+ padding: 0;
+ margin-bottom: 2rem;
+}
+.entries li {
+ background: #23262f;
+ border-radius: 8px;
+ padding: 1rem;
+ margin-bottom: 1rem;
+ box-shadow: 0 1px 4px rgba(78, 84, 200, 0.05);
+ color: #f1f1f1;
+}
+.avatar {
+ border-radius: 50%;
+ vertical-align: middle;
+ margin-right: 0.5rem;
+ object-fit: cover;
+ background: #eee;
+ transition: box-shadow 0.2s;
+}
+.avatar:hover {
+ box-shadow: 0 0 0 3px #8f94fb;
+}
+.avatar-popup {
+ position: fixed;
+ top: 0; left: 0; right: 0; bottom: 0;
+ background: rgba(24,26,32,0.7);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+}
+.avatar-popup-content {
+ background: #23262f;
+ color: #fff;
+ padding: 2rem 2.5rem;
+ border-radius: 12px;
+ box-shadow: 0 4px 24px rgba(78, 84, 200, 0.20);
+ min-width: 260px;
+ text-align: center;
+}
+.avatar-popup-content button {
+ margin-top: 1rem;
+ background: #8f94fb;
+ color: #fff;
+ border: none;
+ border-radius: 6px;
+ padding: 0.5rem 1.2rem;
+ cursor: pointer;
+ font-size: 1rem;
+}
+.avatar-popup-content button:hover {
+ background: #4e54c8;
+}
+form div {
+ margin-bottom: 1rem;
+}
+label {
+ display: block;
+ margin-bottom: 0.3rem;
+ color: #8f94fb;
+}
+input, textarea {
+ width: 100%;
+ padding: 0.6rem;
+ border: 1px solid #353945;
+ border-radius: 6px;
+ font-size: 1rem;
+ background: #23262f;
+ color: #f1f1f1;
+ transition: border 0.2s;
+}
+input:focus, textarea:focus {
+ border-color: #8f94fb;
+ outline: none;
+}
+button[type="submit"] {
+ background: linear-gradient(90deg, #8f94fb 0%, #4e54c8 100%);
+ color: #fff;
+ border: none;
+ border-radius: 6px;
+ padding: 0.7rem 1.5rem;
+ font-size: 1rem;
+ cursor: pointer;
+ transition: background 0.2s, box-shadow 0.2s;
+ box-shadow: 0 2px 8px rgba(78, 84, 200, 0.08);
+}
+button[disabled] {
+ background: #bfc2e6;
+ cursor: not-allowed;
+}
+.validation-message {
+ color: #ff6b6b;
+ font-size: 0.95rem;
+ margin-bottom: 0.5rem;
+ margin-top: -0.5rem;
+}
\ No newline at end of file
diff --git a/src/app/guest-book/comment-panel/comment/comment.component.spec.ts b/src/app/guest-book/comment-panel/comment/comment.component.spec.ts
new file mode 100644
index 0000000..62b66b5
--- /dev/null
+++ b/src/app/guest-book/comment-panel/comment/comment.component.spec.ts
@@ -0,0 +1,28 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ActivatedRoute } from '@angular/router';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+
+import { CommentComponent } from './comment.component';
+
+describe('CommentComponent', () => {
+ let component: CommentComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [HttpClientTestingModule, CommentComponent],
+ providers: [
+ { provide: ActivatedRoute, useValue: { snapshot: {}, params: {} } }
+ ]
+ })
+ .compileComponents();
+
+ fixture = TestBed.createComponent(CommentComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/app/guest-book/comment-panel/comment/comment.component.ts b/src/app/guest-book/comment-panel/comment/comment.component.ts
new file mode 100644
index 0000000..0d3bd02
--- /dev/null
+++ b/src/app/guest-book/comment-panel/comment/comment.component.ts
@@ -0,0 +1,61 @@
+import { Component, signal } from '@angular/core';
+import { IMessage } from './message.model';
+import { FormsModule, NgForm } from '@angular/forms';
+import { CommonModule } from '@angular/common';
+import { CommentsService } from './comments.service';
+import { startWith, Subject, switchMap } from 'rxjs';
+import { toSignal } from '@angular/core/rxjs-interop';
+
+@Component({
+ selector: 'app-comment',
+ standalone: true,
+ imports: [FormsModule, CommonModule],
+ templateUrl: './comment.component.html',
+ styleUrl: './comment.component.scss'
+})
+export class CommentComponent {
+
+ newEntry: IMessage;
+ selectedEntry: IMessage | null = null;
+ private refreshGustMessages$ = new Subject();
+
+ // Convert observable to signal
+ readonly guestMessagesSignal = toSignal(
+ this.refreshGustMessages$.pipe(
+ startWith(null),
+ switchMap(() => this.service.guestMessages$)
+ ),
+ { initialValue: [] as IMessage[] }
+ );
+
+ constructor(private service: CommentsService) {
+ this.newEntry = {
+ id: 0,
+ name: '',
+ message: '',
+ email: '',
+ avatar: 'user-m.png',
+ gender: 'male',
+ date: new Date()
+ };
+ }
+
+ addEntry(form: NgForm) {
+ if (this.newEntry.name && this.newEntry.message && this.newEntry.email) {
+ this.newEntry.date = new Date();
+ this.service.postMessage(this.newEntry).subscribe(entry => {
+ this.refreshGustMessages$.next();
+ });
+ this.newEntry = {
+ id: 0,
+ name: '',
+ message: '',
+ email: '',
+ avatar: 'user-m.png',
+ gender: 'male',
+ date: new Date()
+ };
+ form.resetForm();
+ }
+ }
+}
diff --git a/src/app/guest-book/comment-panel/comment/comments.service.ts b/src/app/guest-book/comment-panel/comment/comments.service.ts
new file mode 100644
index 0000000..f960fb8
--- /dev/null
+++ b/src/app/guest-book/comment-panel/comment/comments.service.ts
@@ -0,0 +1,16 @@
+import { Injectable } from '@angular/core';
+import { IMessage } from './message.model';
+import { HttpClient } from '@angular/common/http';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class CommentsService {
+ readonly guestMessages$ = this.http.get('/api/guest-comments');
+
+ constructor(private http: HttpClient) { }
+
+ postMessage(message: IMessage) {
+ return this.http.post('/api/guest-comments', message);
+ }
+}
diff --git a/src/app/guest-book/comment-panel/comment/message.model.ts b/src/app/guest-book/comment-panel/comment/message.model.ts
new file mode 100644
index 0000000..9a8f053
--- /dev/null
+++ b/src/app/guest-book/comment-panel/comment/message.model.ts
@@ -0,0 +1,9 @@
+export interface IMessage {
+ id: number;
+ name: string;
+ message: string;
+ email: string;
+ date: Date;
+ avatar: string;
+ gender: string;
+ }
\ No newline at end of file
diff --git a/src/app/guest-book/comment-panel/comment/product.model.ts b/src/app/guest-book/comment-panel/comment/product.model.ts
new file mode 100644
index 0000000..02993e3
--- /dev/null
+++ b/src/app/guest-book/comment-panel/comment/product.model.ts
@@ -0,0 +1,7 @@
+export interface IComment {
+ id: number;
+ usderId: string;
+ comment: string;
+ email: string;
+ date: number;
+}
diff --git a/src/app/guest-book/comments.service.spec.ts b/src/app/guest-book/comments.service.spec.ts
new file mode 100644
index 0000000..7f16d0d
--- /dev/null
+++ b/src/app/guest-book/comments.service.spec.ts
@@ -0,0 +1,16 @@
+import { TestBed } from '@angular/core/testing';
+
+import { CommentsService } from './comments.service';
+
+describe('CommentsService', () => {
+ let service: CommentsService;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({});
+ service = TestBed.inject(CommentsService);
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+});
diff --git a/src/app/guest-book/comments.service.ts b/src/app/guest-book/comments.service.ts
new file mode 100644
index 0000000..d6d17bb
--- /dev/null
+++ b/src/app/guest-book/comments.service.ts
@@ -0,0 +1,9 @@
+import { Injectable } from '@angular/core';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class CommentsService {
+
+ constructor() { }
+}
diff --git a/src/app/guest-book/guest-book.component.html b/src/app/guest-book/guest-book.component.html
new file mode 100644
index 0000000..b372842
--- /dev/null
+++ b/src/app/guest-book/guest-book.component.html
@@ -0,0 +1,4 @@
+
diff --git a/src/app/guest-book/guest-book.component.scss b/src/app/guest-book/guest-book.component.scss
new file mode 100644
index 0000000..01f2b84
--- /dev/null
+++ b/src/app/guest-book/guest-book.component.scss
@@ -0,0 +1,105 @@
+.guest-book {
+ max-width: 700px;
+ margin: 2rem auto;
+ background: #181a20;
+ border-radius: 14px;
+ box-shadow: 0 4px 24px rgba(78, 84, 200, 0.10);
+ padding: 2rem 2.5rem;
+ font-family: 'Segoe UI', Arial, sans-serif;
+ color: #f1f1f1;
+}
+.guest-book h2 {
+ color: #8f94fb;
+}
+.entries {
+ list-style: none;
+ padding: 0;
+ margin-bottom: 2rem;
+}
+.entries li {
+ background: #23262f;
+ border-radius: 8px;
+ padding: 1rem;
+ margin-bottom: 1rem;
+ box-shadow: 0 1px 4px rgba(78, 84, 200, 0.05);
+ color: #f1f1f1;
+}
+.avatar {
+ border-radius: 50%;
+ vertical-align: middle;
+ margin-right: 0.5rem;
+ object-fit: cover;
+ background: #eee;
+ transition: box-shadow 0.2s;
+}
+.avatar:hover {
+ box-shadow: 0 0 0 3px #8f94fb;
+}
+.avatar-popup {
+ position: fixed;
+ top: 0; left: 0; right: 0; bottom: 0;
+ background: rgba(24,26,32,0.7);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+}
+.avatar-popup-content {
+ background: #23262f;
+ color: #fff;
+ padding: 2rem 2.5rem;
+ border-radius: 12px;
+ box-shadow: 0 4px 24px rgba(78, 84, 200, 0.20);
+ min-width: 260px;
+ text-align: center;
+}
+.avatar-popup-content button {
+ margin-top: 1rem;
+ background: #8f94fb;
+ color: #fff;
+ border: none;
+ border-radius: 6px;
+ padding: 0.5rem 1.2rem;
+ cursor: pointer;
+ font-size: 1rem;
+}
+.avatar-popup-content button:hover {
+ background: #4e54c8;
+}
+form div {
+ margin-bottom: 1rem;
+}
+label {
+ display: block;
+ margin-bottom: 0.3rem;
+ color: #8f94fb;
+}
+input, textarea {
+ width: 100%;
+ padding: 0.6rem;
+ border: 1px solid #353945;
+ border-radius: 6px;
+ font-size: 1rem;
+ background: #23262f;
+ color: #f1f1f1;
+ transition: border 0.2s;
+}
+input:focus, textarea:focus {
+ border-color: #8f94fb;
+ outline: none;
+}
+button[type="submit"] {
+ background: linear-gradient(90deg, #8f94fb 0%, #4e54c8 100%);
+ color: #fff;
+ border: none;
+ border-radius: 6px;
+ padding: 0.7rem 1.5rem;
+ font-size: 1rem;
+ cursor: pointer;
+ transition: background 0.2s, box-shadow 0.2s;
+ box-shadow: 0 2px 8px rgba(78, 84, 200, 0.08);
+}
+button[disabled] {
+ background: #bfc2e6;
+ cursor: not-allowed;
+}
\ No newline at end of file
diff --git a/src/app/guest-book/guest-book.component.spec.ts b/src/app/guest-book/guest-book.component.spec.ts
new file mode 100644
index 0000000..f8b5a3a
--- /dev/null
+++ b/src/app/guest-book/guest-book.component.spec.ts
@@ -0,0 +1,24 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+
+import { GuestBookComponent } from './guest-book.component';
+
+describe('GuestBookComponent', () => {
+ let component: GuestBookComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [HttpClientTestingModule, GuestBookComponent]
+ })
+ .compileComponents();
+
+ fixture = TestBed.createComponent(GuestBookComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/app/guest-book/guest-book.component.ts b/src/app/guest-book/guest-book.component.ts
new file mode 100644
index 0000000..2bd635d
--- /dev/null
+++ b/src/app/guest-book/guest-book.component.ts
@@ -0,0 +1,15 @@
+import { Component } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { CommonModule } from '@angular/common';
+import { CommentComponent } from './comment-panel/comment/comment.component';
+
+@Component({
+ selector: 'app-guest-book',
+ imports: [CommentComponent, FormsModule, CommonModule],
+ standalone: true,
+ templateUrl: './guest-book.component.html',
+ styleUrl: './guest-book.component.scss'
+})
+export class GuestBookComponent {
+
+}
diff --git a/src/app/login/login.component.html b/src/app/login/login.component.html
new file mode 100644
index 0000000..60fd1e0
--- /dev/null
+++ b/src/app/login/login.component.html
@@ -0,0 +1,57 @@
+
+
+ Login
+
+
+
+
+
+ Login
+
+
diff --git a/src/app/login/login.component.scss b/src/app/login/login.component.scss
new file mode 100644
index 0000000..c2ce341
--- /dev/null
+++ b/src/app/login/login.component.scss
@@ -0,0 +1,61 @@
+
+.login-container {
+ max-width: 350px;
+ margin: 5vh auto;
+ padding: 2rem 2rem 1.5rem 2rem;
+ background: #181a20;
+ border-radius: 10px;
+ box-shadow: 0 4px 24px rgba(78, 84, 200, 0.10);
+ color: #fff;
+ font-family: 'Segoe UI', Arial, sans-serif;
+}
+h2 {
+ text-align: center;
+ margin-bottom: 1.5rem;
+ color: #8f94fb;
+}
+.form-group {
+ margin-bottom: 1.2rem;
+}
+label {
+ display: block;
+ margin-bottom: 0.4rem;
+ color: #bfc2e6;
+ font-size: 1rem;
+}
+.input {
+ width: 100%;
+ padding: 0.6rem;
+ border-radius: 5px;
+ border: 1px solid #4e54c8;
+ background: #23263a;
+ color: #fff;
+ font-size: 1rem;
+ margin-bottom: 0.2rem;
+}
+.input:focus {
+ outline: none;
+ border-color: #8f94fb;
+}
+.validation-message {
+ color: #ff6b6b;
+ font-size: 0.95rem;
+ margin-bottom: 0.2rem;
+}
+.login-btn {
+ width: 100%;
+ padding: 0.7rem;
+ background: linear-gradient(90deg, #8f94fb 0%, #4e54c8 100%);
+ color: #fff;
+ border: none;
+ border-radius: 6px;
+ font-size: 1.1rem;
+ font-weight: bold;
+ cursor: pointer;
+ transition: background 0.2s;
+ margin-top: 0.5rem;
+}
+.login-btn:disabled {
+ background: #444a7c;
+ cursor: not-allowed;
+}
\ No newline at end of file
diff --git a/src/app/login/login.component.spec.ts b/src/app/login/login.component.spec.ts
new file mode 100644
index 0000000..18f3685
--- /dev/null
+++ b/src/app/login/login.component.spec.ts
@@ -0,0 +1,23 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { LoginComponent } from './login.component';
+
+describe('LoginComponent', () => {
+ let component: LoginComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [LoginComponent]
+ })
+ .compileComponents();
+
+ fixture = TestBed.createComponent(LoginComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/app/login/login.component.ts b/src/app/login/login.component.ts
new file mode 100644
index 0000000..46d8332
--- /dev/null
+++ b/src/app/login/login.component.ts
@@ -0,0 +1,29 @@
+import { Component } from '@angular/core';
+import { FormsModule, NgForm } from '@angular/forms';
+import { Router } from '@angular/router';
+
+@Component({
+ selector: 'app-login',
+ imports: [FormsModule],
+ templateUrl: './login.component.html',
+ styleUrl: './login.component.scss'
+})
+export class LoginComponent {
+ loginData = {
+ email: '',
+ password: ''
+ };
+
+ constructor(private router: Router) {}
+
+ login(loginForm: NgForm) {
+ // Simulate login logic
+ if (this.loginData.email && this.loginData.password) {
+ console.log('User logged in:', this.loginData.email);
+ // Here you would typically call a service to handle the login
+ // and store the token in localStorage or a similar mechanism.
+ localStorage.setItem('token', 'dummy-token'); // Simulated token
+ this.router.navigate(['/blogs']); // Redirect to /blogs after login
+ }
+ }
+}
diff --git a/src/app/not-found/not-found.component.html b/src/app/not-found/not-found.component.html
new file mode 100644
index 0000000..7879406
--- /dev/null
+++ b/src/app/not-found/not-found.component.html
@@ -0,0 +1,6 @@
+
+
404
+
Page Not Found
+
The page you are looking for doesn’t exist or has been moved.
+
Go to Home
+
\ No newline at end of file
diff --git a/src/app/not-found/not-found.component.scss b/src/app/not-found/not-found.component.scss
new file mode 100644
index 0000000..c813c9e
--- /dev/null
+++ b/src/app/not-found/not-found.component.scss
@@ -0,0 +1,44 @@
+.not-found-container {
+ max-width: 400px;
+ margin: 8vh auto;
+ padding: 2.5rem 2rem;
+ background: #181a20;
+ border-radius: 12px;
+ box-shadow: 0 4px 24px rgba(78, 84, 200, 0.10);
+ text-align: center;
+ color: #fff;
+ font-family: 'Segoe UI', Arial, sans-serif;
+}
+.not-found-container h1 {
+ font-size: 5rem;
+ margin: 0 0 0.5rem 0;
+ color: #ff6b6b;
+ font-weight: bold;
+ letter-spacing: 2px;
+}
+.not-found-container h2 {
+ font-size: 2rem;
+ margin-bottom: 1rem;
+ color: #8f94fb;
+}
+.not-found-container p {
+ margin-bottom: 2rem;
+ color: #bfc2e6;
+}
+.back-home-btn {
+ display: inline-block;
+ padding: 0.7rem 2rem;
+ background: linear-gradient(90deg, #8f94fb 0%, #4e54c8 100%);
+ color: #fff;
+ border: none;
+ border-radius: 6px;
+ font-size: 1.1rem;
+ font-weight: bold;
+ text-decoration: none;
+ transition: background 0.2s, box-shadow 0.2s;
+ box-shadow: 0 2px 8px rgba(78, 84, 200, 0.08);
+ cursor: pointer;
+}
+.back-home-btn:hover {
+ background: linear-gradient(90deg, #4e54c8 0%, #8f94fb 100%);
+}
\ No newline at end of file
diff --git a/src/app/not-found/not-found.component.spec.ts b/src/app/not-found/not-found.component.spec.ts
new file mode 100644
index 0000000..5b65d9e
--- /dev/null
+++ b/src/app/not-found/not-found.component.spec.ts
@@ -0,0 +1,23 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { NotFoundComponent } from './not-found.component';
+
+describe('NotFoundComponent', () => {
+ let component: NotFoundComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [NotFoundComponent]
+ })
+ .compileComponents();
+
+ fixture = TestBed.createComponent(NotFoundComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/app/not-found/not-found.component.ts b/src/app/not-found/not-found.component.ts
new file mode 100644
index 0000000..a5a7c05
--- /dev/null
+++ b/src/app/not-found/not-found.component.ts
@@ -0,0 +1,13 @@
+import { Component } from '@angular/core';
+import { RouterModule } from '@angular/router';
+
+@Component({
+ selector: 'app-not-found',
+ imports: [RouterModule],
+ templateUrl: './not-found.component.html',
+ styleUrl: './not-found.component.scss',
+ standalone: true,
+})
+export class NotFoundComponent {
+
+}
diff --git a/src/app/register/register.component.html b/src/app/register/register.component.html
new file mode 100644
index 0000000..178dc08
--- /dev/null
+++ b/src/app/register/register.component.html
@@ -0,0 +1,42 @@
+
diff --git a/src/app/register/register.component.scss b/src/app/register/register.component.scss
new file mode 100644
index 0000000..b59d30b
--- /dev/null
+++ b/src/app/register/register.component.scss
@@ -0,0 +1,65 @@
+.register-container {
+ max-width: 350px;
+ margin: 3rem auto;
+ background: #181a20;
+ border-radius: 12px;
+ box-shadow: 0 4px 24px rgba(78, 84, 200, 0.10);
+ padding: 2rem 2.5rem;
+ color: #fff;
+ font-family: 'Segoe UI', Arial, sans-serif;
+}
+
+.register-container h2 {
+ color: #8f94fb;
+ margin-bottom: 1.5rem;
+ text-align: center;
+}
+
+.form-group {
+ margin-bottom: 1.2rem;
+}
+
+label {
+ display: block;
+ margin-bottom: 0.4rem;
+ color: #8f94fb;
+ font-weight: 500;
+}
+
+.input {
+ width: 100%;
+ padding: 0.7rem;
+ border: 1px solid #353945;
+ border-radius: 6px;
+ font-size: 1rem;
+ background: #23262f;
+ color: #fff;
+ transition: border 0.2s;
+ box-sizing: border-box;
+}
+
+.input:focus {
+ border-color: #4e54c8;
+ outline: none;
+}
+
+.register-btn {
+ width: 100%;
+ background: linear-gradient(90deg, #8f94fb 0%, #4e54c8 100%);
+ color: #fff;
+ border: none;
+ border-radius: 6px;
+ padding: 0.8rem 0;
+ font-size: 1.1rem;
+ font-weight: bold;
+ cursor: pointer;
+ transition: background 0.2s, box-shadow 0.2s;
+ box-shadow: 0 2px 8px rgba(78, 84, 200, 0.08);
+ display: block;
+ margin-top: 0.5rem;
+}
+
+.register-btn:disabled {
+ background: #bfc2e6;
+ cursor: not-allowed;
+}
\ No newline at end of file
diff --git a/src/app/register/register.component.spec.ts b/src/app/register/register.component.spec.ts
new file mode 100644
index 0000000..757b895
--- /dev/null
+++ b/src/app/register/register.component.spec.ts
@@ -0,0 +1,23 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { RegisterComponent } from './register.component';
+
+describe('RegisterComponent', () => {
+ let component: RegisterComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [RegisterComponent]
+ })
+ .compileComponents();
+
+ fixture = TestBed.createComponent(RegisterComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/app/register/register.component.ts b/src/app/register/register.component.ts
new file mode 100644
index 0000000..c8a1402
--- /dev/null
+++ b/src/app/register/register.component.ts
@@ -0,0 +1,30 @@
+import { Component } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+
+@Component({
+ selector: 'app-register',
+ standalone: true,
+ imports: [FormsModule],
+ templateUrl: './register.component.html',
+ styleUrl: './register.component.scss'
+})
+export class RegisterComponent {
+ email = '';
+ name = '';
+ password = '';
+ confirmPassword = '';
+ showPassword = false;
+
+ onRegister() {
+ if (this.password === this.confirmPassword) {
+ console.log('User registered:', this.email);
+ } else {
+ console.error('Passwords do not match');
+ }
+ }
+
+ togglePasswordVisibility() {
+ this.showPassword = !this.showPassword;
+ }
+
+}
diff --git a/src/app/site-header/site-header.component.html b/src/app/site-header/site-header.component.html
new file mode 100644
index 0000000..df5394f
--- /dev/null
+++ b/src/app/site-header/site-header.component.html
@@ -0,0 +1,11 @@
+
\ No newline at end of file
diff --git a/src/app/site-header/site-header.component.scss b/src/app/site-header/site-header.component.scss
new file mode 100644
index 0000000..5434fcb
--- /dev/null
+++ b/src/app/site-header/site-header.component.scss
@@ -0,0 +1,114 @@
+.site-header {
+ background: linear-gradient(90deg, #4e54c8 0%, #8f94fb 100%);
+ padding: 0.5rem 0;
+ box-shadow: 0 2px 8px rgba(78, 84, 200, 0.1);
+}
+
+.nav-tabs {
+ display: flex;
+ justify-content: center;
+ gap: 2rem;
+}
+
+.tab {
+ color: #fff;
+ text-decoration: none;
+ font-size: 1.2rem;
+ padding: 0.5rem 1.5rem;
+ border-radius: 25px;
+ transition: background 0.2s, color 0.2s;
+ font-weight: 500;
+ letter-spacing: 1px;
+}
+
+.tab:hover,
+.tab.active {
+ background: #fff;
+ color: #4e54c8;
+ box-shadow: 0 2px 8px rgba(78, 84, 200, 0.15);
+}
+.auth-buttons {
+ margin-left: auto;
+ display: flex;
+ gap: 0.5rem;
+}
+.auth-btn {
+ background: #8f94fb;
+ color: #fff;
+ border: none;
+ border-radius: 6px;
+ padding: 0.4rem 1rem;
+ cursor: pointer;
+ font-size: 1rem;
+ transition: background 0.2s;
+}
+.auth-btn:hover {
+ background: #4e54c8;
+}
+.login-popup-backdrop {
+ position: fixed;
+ top: 0; left: 0; right: 0; bottom: 0;
+ background: rgba(24, 26, 32, 0.85);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+}
+.login-popup {
+ background: #181a20;
+ border-radius: 12px;
+ box-shadow: 0 4px 24px rgba(78, 84, 200, 0.15);
+ padding: 2rem 2.5rem 1.5rem 2.5rem;
+ color: #fff;
+ min-width: 320px;
+ position: relative;
+}
+.close-btn {
+ position: absolute;
+ top: 1rem;
+ right: 1rem;
+ background: transparent;
+ border: none;
+ color: #fff;
+ font-size: 2rem;
+ cursor: pointer;
+ line-height: 1;
+}
+.form-group {
+ margin-bottom: 1.2rem;
+}
+.input {
+ width: 100%;
+ padding: 0.7rem;
+ border: 1px solid #353945;
+ border-radius: 6px;
+ font-size: 1rem;
+ background: #23262f;
+ color: #fff;
+ transition: border 0.2s;
+ box-sizing: border-box;
+}
+.input:focus {
+ border-color: #4e54c8;
+ outline: none;
+}
+.sign-in-btn {
+ width: 100%;
+ background: linear-gradient(90deg, #8f94fb 0%, #4e54c8 100%);
+ color: #fff;
+ border: none;
+ border-radius: 6px;
+ padding: 0.8rem 0;
+ font-size: 1.1rem;
+ font-weight: bold;
+ cursor: pointer;
+ transition: background 0.2s, box-shadow 0.2s;
+ box-shadow: 0 2px 8px rgba(78, 84, 200, 0.08);
+ display: block;
+ /* Ensures the button matches the input width */
+ margin-top: 0.5rem;
+}
+.sign-in-btn:disabled {
+ background: #bfc2e6;
+ cursor: not-allowed;
+}
diff --git a/src/app/site-header/site-header.component.spec.ts b/src/app/site-header/site-header.component.spec.ts
new file mode 100644
index 0000000..be01636
--- /dev/null
+++ b/src/app/site-header/site-header.component.spec.ts
@@ -0,0 +1,27 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ActivatedRoute } from '@angular/router';
+
+import { SiteHeaderComponent } from './site-header.component';
+
+describe('SiteHeaderComponent', () => {
+ let component: SiteHeaderComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [SiteHeaderComponent],
+ providers: [
+ { provide: ActivatedRoute, useValue: { snapshot: {}, params: {} } }
+ ]
+ })
+ .compileComponents();
+
+ fixture = TestBed.createComponent(SiteHeaderComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/app/site-header/site-header.component.ts b/src/app/site-header/site-header.component.ts
new file mode 100644
index 0000000..62c4ca0
--- /dev/null
+++ b/src/app/site-header/site-header.component.ts
@@ -0,0 +1,29 @@
+import { CommonModule } from '@angular/common';
+import { Component, signal } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { RouterModule} from '@angular/router';
+
+@Component({
+ selector: 'app-site-header',
+ imports: [RouterModule, CommonModule, FormsModule],
+ templateUrl: './site-header.component.html',
+ styleUrl: './site-header.component.scss',
+ standalone: true,
+})
+export class SiteHeaderComponent {
+
+ showLoginPopup = false;
+ email = '';
+ password = '';
+ readonly isSignedInSignal = signal(!!localStorage.getItem('token'));
+
+ onSignIn() {
+ this.showLoginPopup = false;
+ }
+
+ logout() {
+ localStorage.removeItem('token');
+ this.isSignedInSignal.set(false);
+ }
+
+}
diff --git a/src/assets/avatars/man-1.png b/src/assets/avatars/man-1.png
new file mode 100644
index 0000000..46aaedb
Binary files /dev/null and b/src/assets/avatars/man-1.png differ
diff --git a/src/assets/avatars/man-2.png b/src/assets/avatars/man-2.png
new file mode 100644
index 0000000..c04a944
Binary files /dev/null and b/src/assets/avatars/man-2.png differ
diff --git a/src/assets/avatars/user-f.png b/src/assets/avatars/user-f.png
new file mode 100644
index 0000000..ff20a20
Binary files /dev/null and b/src/assets/avatars/user-f.png differ
diff --git a/src/assets/avatars/user-m.png b/src/assets/avatars/user-m.png
new file mode 100644
index 0000000..2be4f03
Binary files /dev/null and b/src/assets/avatars/user-m.png differ
diff --git a/src/assets/avatars/woman.png b/src/assets/avatars/woman.png
new file mode 100644
index 0000000..7323b97
Binary files /dev/null and b/src/assets/avatars/woman.png differ
diff --git a/src/main.ts b/src/main.ts
index 75a3f6a..e17ab74 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -2,8 +2,12 @@ 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';
bootstrapApplication(AppComponent, {
- providers: [provideRouter(appRoutes)]
+ providers: [
+ provideRouter(appRoutes),
+ provideHttpClient()
+ ]
})
.catch(err => console.error(err));
diff --git a/src/proxy.conf.json b/src/proxy.conf.json
new file mode 100644
index 0000000..99b4828
--- /dev/null
+++ b/src/proxy.conf.json
@@ -0,0 +1,6 @@
+{
+ "/api": {
+ "target": "http://localhost:3000",
+ "secure": false
+ }
+}
\ No newline at end of file
diff --git a/src/styles/colors.scss b/src/styles/colors.scss
new file mode 100644
index 0000000..5267c07
--- /dev/null
+++ b/src/styles/colors.scss
@@ -0,0 +1,18 @@
+$color-bg-main: #181a20;
+$color-bg-secondary: #23262f;
+$color-bg-accent: #23263a;
+$color-bg-light: #fff;
+
+$color-primary: #4e54c8;
+$color-primary-light: #8f94fb;
+$color-secondary: #bfc2e6;
+$color-accent: #ffd700;
+
+$color-text-main: #fff;
+$color-text-secondary: #bfc2e6;
+$color-text-muted: #e0e0e0;
+$color-text-error: #ff6b6b;
+$color-text-link: #8f94fb;
+
+$color-shadow: rgba(78, 84, 200, 0.10);
+$color-shadow-strong: rgba(78, 84, 200, 0.15);
\ No newline at end of file
diff --git a/src/styles/fonts.scss b/src/styles/fonts.scss
new file mode 100644
index 0000000..1c7bb5d
--- /dev/null
+++ b/src/styles/fonts.scss
@@ -0,0 +1,5 @@
+$font-main: 'Segoe UI', Arial, sans-serif;
+$font-size-base: 1rem;
+$font-size-lg: 1.2rem;
+$font-size-sm: 0.95rem;
+$font-weight-bold: 500;
\ No newline at end of file
diff --git a/src/styles/spacing.scss b/src/styles/spacing.scss
new file mode 100644
index 0000000..fd19230
--- /dev/null
+++ b/src/styles/spacing.scss
@@ -0,0 +1,8 @@
+$padding-base: 1rem;
+$padding-lg: 2rem;
+$padding-sm: 0.5rem;
+$margin-base: 1rem;
+$margin-lg: 2rem;
+$margin-sm: 0.5rem;
+$border-radius: 8px;
+$border-radius-lg: 14px;
\ No newline at end of file
diff --git a/tsconfig.json b/tsconfig.json
index 4ec7ddc..398ca04 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,4 +1,3 @@
-/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"compileOnSave": false,
"compilerOptions": {
@@ -28,6 +27,8 @@
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
- "strictTemplates": true
+ "strictTemplates": true,
+ "enableTemplateStatements": true,
+ "enableControlFlow": true
}
}
Comments
+ @if (commentsSignal(); as comments) { + @if (comments.length === 0) { ++ @for (comment of comments; track comment.id) { +-
+
+ {{ comment.name }}
+ {{ comment.date | date:'short' }}
+
+
+
+ }
+
+ } + } +