+
+
+ @if (error) {
+ {{ error }}
+ }
+
+
+ New project
+
+
+
+
+
+
+
+ @if (loading) {
+ Loading projects...
+ } @else if (projects.length === 0) {
+ No projects yet. Create the first project to start managing to-do items.
+ } @else {
+
+ @for (project of projects; track project.id) {
+
+ {{ project.name }}
+ @if (project.description) {
+ {{ project.description }}
+ }
+
+
+ }
+
+ }
+
diff --git a/src/Web/ClientApp/src/app/projects/projects.component.scss b/src/Web/ClientApp/src/app/projects/projects.component.scss
new file mode 100644
index 000000000..943db7fae
--- /dev/null
+++ b/src/Web/ClientApp/src/app/projects/projects.component.scss
@@ -0,0 +1,58 @@
+.projects-page {
+ padding-block: 1rem;
+}
+
+.page-header {
+ margin-bottom: 1rem;
+}
+
+.create-project-card {
+ margin-bottom: 1rem;
+}
+
+.form-grid {
+ display: grid;
+ gap: 1rem;
+ grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
+}
+
+.project-grid {
+ display: grid;
+ gap: 1rem;
+ grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
+}
+
+.project-card {
+ cursor: pointer;
+}
+
+.project-card:focus,
+.project-card:hover {
+ border-color: var(--pico-primary);
+}
+
+.error {
+ color: var(--pico-del-color);
+}
+
+:host-context(html[data-theme="light"]) .projects-page button:not(.secondary):not(.outline) {
+ color: #e5e7eb;
+}
+
+@media (prefers-color-scheme: light) {
+ :host-context(html:not([data-theme="dark"])) .projects-page button:not(.secondary):not(.outline) {
+ color: #e5e7eb;
+ }
+}
+
+:host-context(html[data-theme="light"]) .project-card > h2,
+:host-context(html[data-theme="light"]) .project-card > p {
+ color: #e5e7eb;
+}
+
+@media (prefers-color-scheme: light) {
+ :host-context(html:not([data-theme="dark"])) .project-card > h2,
+ :host-context(html:not([data-theme="dark"])) .project-card > p {
+ color: #e5e7eb;
+ }
+}
diff --git a/src/Web/ClientApp/src/app/projects/projects.component.ts b/src/Web/ClientApp/src/app/projects/projects.component.ts
new file mode 100644
index 000000000..07f04bc45
--- /dev/null
+++ b/src/Web/ClientApp/src/app/projects/projects.component.ts
@@ -0,0 +1,112 @@
+import { ChangeDetectorRef, Component, NgZone, OnInit } from '@angular/core';
+import { Router } from '@angular/router';
+import { CreateProjectCommand, ProjectDto, ProjectsClient } from '../web-api-client';
+
+@Component({
+ standalone: false,
+ selector: 'app-projects',
+ templateUrl: './projects.component.html',
+ styleUrls: ['./projects.component.scss']
+})
+export class ProjectsComponent implements OnInit {
+ projects: ProjectDto[] = [];
+ loading = true;
+ saving = false;
+ error = '';
+ newProject = { name: '', description: '' };
+
+ constructor(
+ private readonly projectsClient: ProjectsClient,
+ private readonly router: Router,
+ private readonly zone: NgZone,
+ private readonly cdr: ChangeDetectorRef
+ ) { }
+
+ ngOnInit(): void {
+ this.loadProjects();
+ }
+
+ loadProjects(): void {
+ this.loading = true;
+ this.error = '';
+ this.cdr.detectChanges();
+
+ this.projectsClient.getProjects().subscribe({
+ next: response => {
+ this.zone.run(() => {
+ this.projects = this.normalizeProjects(response);
+ this.loading = false;
+ this.cdr.detectChanges();
+ });
+ },
+ error: error => {
+ this.zone.run(() => {
+ console.error('Unable to load projects.', error);
+ this.error = 'Unable to load projects.';
+ this.loading = false;
+ this.cdr.detectChanges();
+ });
+ }
+ });
+ }
+
+ createProject(): void {
+ const name = this.newProject.name.trim();
+
+ if (!name) {
+ return;
+ }
+
+ const command = new CreateProjectCommand({
+ name,
+ description: this.newProject.description || undefined
+ });
+
+ this.saving = true;
+ this.error = '';
+ this.cdr.detectChanges();
+
+ this.projectsClient.createProject(command).subscribe({
+ next: id => {
+ this.zone.run(() => {
+ this.newProject = { name: '', description: '' };
+ this.saving = false;
+ this.cdr.detectChanges();
+ void this.router.navigate(['/projects', id]);
+ });
+ },
+ error: error => {
+ this.zone.run(() => {
+ console.error('Unable to create project.', error);
+ this.error = 'Unable to create project.';
+ this.saving = false;
+ this.cdr.detectChanges();
+ });
+ }
+ });
+ }
+
+ openProject(project: ProjectDto): void {
+ this.router.navigate(['/projects', project.id]);
+ }
+
+ private normalizeProjects(response: ProjectDto[] | ProjectDto | { items?: ProjectDto[] } | null | undefined): ProjectDto[] {
+ if (!response) {
+ return [];
+ }
+
+ if (Array.isArray(response)) {
+ return response;
+ }
+
+ if ('items' in response && Array.isArray(response.items)) {
+ return response.items;
+ }
+
+ if (typeof response === 'object' && 'id' in response) {
+ return [response as ProjectDto];
+ }
+
+ return [];
+ }
+}
diff --git a/src/Web/DependencyInjection.cs b/src/Web/DependencyInjection.cs
index cbda534d0..334173038 100644
--- a/src/Web/DependencyInjection.cs
+++ b/src/Web/DependencyInjection.cs
@@ -2,7 +2,9 @@
using CleanArchitecture.Application.Common.Interfaces;
using CleanArchitecture.Infrastructure.Data;
using CleanArchitecture.Web.Services;
+using CleanArchitecture.Web.Hubs;
using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.SignalR;
namespace Microsoft.Extensions.DependencyInjection;
@@ -34,6 +36,9 @@ public static void AddWebServices(this IHostApplicationBuilder builder)
});
builder.Services.AddCors();
+ builder.Services.AddSignalR();
+ builder.Services.AddSingleton