Compare commits

...

31 commits

Author SHA1 Message Date
54b4753c7c
Merge pull request #1 from a-mayb3/devel
Minimum viable product ready
2026-02-11 21:44:52 +01:00
0811bf4224
minor changes 2026-02-11 21:37:11 +01:00
cd041d04cd Fixed projects not showing on login 2026-02-11 11:56:40 +01:00
7202ed8806 substituted @ifs and @fors with deprecated ngIf and ngFor 2026-02-11 11:24:48 +01:00
171400f9ca refactored and changed px for relative units 2026-02-11 11:23:24 +01:00
effcab5947 Changed layout for project detail page 2026-02-11 11:09:54 +01:00
69edc4e197 Added task editing page 2026-02-11 10:50:13 +01:00
ffc79a28dc Forced angular analytics off 2026-02-11 10:42:21 +01:00
377c8b6146 minor changes /j 2026-02-10 13:51:55 +01:00
f7f12356de Added completion status in task item 2026-02-10 13:51:02 +01:00
a7f506b873 Added completion rate to project item 2026-02-10 13:50:02 +01:00
0c8f11a463 Added user registration 2026-02-10 13:49:14 +01:00
1c19bb4e7d Added collaborator item for project-detail page 2026-02-10 11:50:23 +01:00
ddb9aa0b32 Created basic task item component for project-detail 2026-02-10 11:50:03 +01:00
c5b21f166d Created project managment page 2026-02-10 11:49:40 +01:00
dd043b3385 Created task creation page 2026-02-10 11:49:14 +01:00
782f58a4a2 Made the navbar logo redirect to homepage 2026-02-10 11:31:47 +01:00
c59090a64a Made project item redirect to project details on click 2026-02-10 11:31:06 +01:00
d31630db18
minor fixes 2026-02-09 22:46:54 +01:00
d1e016b7df
Adapted some config for the api 2026-02-09 22:46:16 +01:00
773fa7ad6d
Started working on project creation form page 2026-02-09 21:57:36 +01:00
2f3f8354a7
Moved and modified navbar component 2026-02-09 21:53:13 +01:00
deeb226102
Project item to show in dashboard 2026-02-09 21:52:25 +01:00
3ee36a2e89
Basic footer component. TODO: finish footer component 2026-02-09 21:52:04 +01:00
4e5be55079
Including some usage examples 2026-02-09 20:19:23 +01:00
32891140e2
Started making global navbar component 2026-02-09 20:19:05 +01:00
bff5e1dcf7
Started working on homepage dashboard 2026-02-09 20:18:24 +01:00
1d39dffd56
Base login page 2026-02-09 20:17:59 +01:00
0d82ea47c3
changed base project files 2026-02-09 20:17:34 +01:00
2cf0856db5
Base api connection requirements and models 2026-02-09 20:13:12 +01:00
9ab0505ed4
Angular project generation 2026-02-09 18:23:19 +01:00
74 changed files with 13366 additions and 23 deletions

17
.editorconfig Normal file
View file

@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false

62
.gitignore vendored
View file

@ -1,28 +1,44 @@
# Angular specific
/dist/
/out-tsc/
/tmp/
/coverage/
/e2e/test-output/
/.angular/
.angular/
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Node modules and dependency files
/node_modules/
/package-lock.json
/yarn.lock
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Environment files
/.env
# Node
/node_modules
npm-debug.log
yarn-error.log
# Angular CLI and build artefacts
/.angular-cli.json
/.ng/
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# TypeScript cache
*.tsbuildinfo
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/mcp.json
.history/*
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
__screenshots__/
# System files
.DS_Store
Thumbs.db

4
.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}

20
.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,20 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}

9
.vscode/mcp.json vendored Normal file
View file

@ -0,0 +1,9 @@
{
// For more information, visit: https://angular.dev/ai/mcp
"servers": {
"angular-cli": {
"command": "npx",
"args": ["-y", "@angular/cli", "mcp"]
}
}
}

42
.vscode/tasks.json vendored Normal file
View file

@ -0,0 +1,42 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
}
]
}

59
README.md Normal file
View file

@ -0,0 +1,59 @@
# KanbanCloneAngular
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.1.3.
## Development server
To start a local development server, run:
```bash
ng serve
```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
## Code scaffolding
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
ng generate component component-name
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```bash
ng generate --help
```
## Building
To build the project run:
```bash
ng build
```
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.

279
USAGE_EXAMPLES.ts Normal file
View file

@ -0,0 +1,279 @@
/**
* USAGE EXAMPLES - How to use the authentication and API services
*
* Delete this file once you're familiar with the patterns
*/
// ============================================
// 1. LOGIN COMPONENT EXAMPLE
// ============================================
import { Component, inject, signal } from '@angular/core';
import { Router } from '@angular/router';
import { AuthService } from './services/auth.service';
import { FormsModule } from '@angular/forms';
/*
@Component({
selector: 'app-login',
standalone: true,
imports: [FormsModule],
template: `
<div class="login-form">
<h2>Login</h2>
<form (ngSubmit)="onSubmit()">
<input
[(ngModel)]="username"
name="username"
placeholder="Username"
required>
<input
[(ngModel)]="password"
name="password"
type="password"
placeholder="Password"
required>
<button type="submit">Login</button>
</form>
@if (errorMessage()) {
<p class="error">{{ errorMessage() }}</p>
}
</div>
`
})
export class LoginComponent {
private authService = inject(AuthService);
private router = inject(Router);
username = '';
password = '';
errorMessage = signal('');
onSubmit() {
this.authService.login({
username: this.username,
password: this.password
}).subscribe({
next: (response) => {
if (response.success) {
// Navigate to dashboard on successful login
this.router.navigate(['/dashboard']);
} else {
this.errorMessage.set(response.message || 'Login failed');
}
},
error: (err) => {
this.errorMessage.set('Login error: ' + err.message);
}
});
}
}
*/
// ============================================
// 2. DASHBOARD COMPONENT WITH AUTH STATE
// ============================================
/*
@Component({
selector: 'app-dashboard',
standalone: true,
template: `
<div class="dashboard">
<h1>Dashboard</h1>
@if (authService.currentUser()) {
<p>Welcome, {{ authService.currentUser()?.username }}!</p>
}
<button (click)="logout()">Logout</button>
</div>
`
})
export class DashboardComponent {
protected authService = inject(AuthService);
logout() {
this.authService.logout().subscribe({
next: () => {
console.log('Logged out successfully');
},
error: (err) => {
console.error('Logout error:', err);
}
});
}
}
*/
// ============================================
// 3. MAKING API CALLS WITH ApiService
// ============================================
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { ApiService } from './services/api.service';
// Example: Define your data models
interface Task {
id: number;
title: string;
completed: boolean;
}
/*
@Injectable({
providedIn: 'root'
})
export class TaskService {
private apiService = inject(ApiService);
// Get all tasks
getAllTasks(): Observable<Task[]> {
return this.apiService.get<Task[]>('/tasks');
}
// Get single task
getTask(id: number): Observable<Task> {
return this.apiService.get<Task>(`/tasks/${id}`);
}
// Create task
createTask(task: Partial<Task>): Observable<Task> {
return this.apiService.post<Task>('/tasks', task);
}
// Update task
updateTask(id: number, task: Partial<Task>): Observable<Task> {
return this.apiService.put<Task>(`/tasks/${id}`, task);
}
// Delete task
deleteTask(id: number): Observable<void> {
return this.apiService.delete<void>(`/tasks/${id}`);
}
}
*/
// ============================================
// 4. DIRECT HttpClient USAGE (Alternative)
// ============================================
import { HttpClient } from '@angular/common/http';
import { environment } from './config/environment';
/*
@Injectable({
providedIn: 'root'
})
export class CustomService {
private http = inject(HttpClient);
getData(): Observable<any> {
// Cookies will be automatically sent due to the interceptor
return this.http.get(`${environment.apiBaseUrl}/custom-endpoint`);
}
postData(data: any): Observable<any> {
return this.http.post(`${environment.apiBaseUrl}/custom-endpoint`, data);
}
}
*/
// ============================================
// 5. APP INITIALIZATION - Check Session on Startup
// ============================================
import { APP_INITIALIZER } from '@angular/core';
import { catchError, of } from 'rxjs';
/*
// Add this to your app.config.ts providers array:
export function initializeApp(authService: AuthService) {
return () => authService.checkSession().pipe(
catchError(() => {
// Session check failed - user is not logged in
console.log('No active session');
return of(null);
})
);
}
// In app.config.ts providers:
{
provide: APP_INITIALIZER,
useFactory: (authService: AuthService) => () => initializeApp(authService),
deps: [AuthService],
multi: true
}
*/
// ============================================
// 6. COMPONENT WITH REACTIVE AUTH STATE
// ============================================
/*
@Component({
selector: 'app-header',
standalone: true,
template: `
<header>
@if (authService.isAuthenticated()) {
<nav>
<a routerLink="/dashboard">Dashboard</a>
<a routerLink="/profile">Profile</a>
<button (click)="logout()">Logout</button>
</nav>
} @else {
<nav>
<a routerLink="/login">Login</a>
</nav>
}
</header>
`
})
export class HeaderComponent {
protected authService = inject(AuthService);
logout() {
this.authService.logout().subscribe();
}
}
*/
// ============================================
// 7. PROTECTED ROUTES CONFIGURATION
// ============================================
/*
import { Routes } from '@angular/router';
import { authGuard } from './guards/auth.guard';
export const routes: Routes = [
// Public routes
{ path: 'login', component: LoginComponent },
{ path: 'register', component: RegisterComponent },
// Protected routes
{
path: 'dashboard',
component: DashboardComponent,
canActivate: [authGuard]
},
{
path: 'profile',
component: ProfileComponent,
canActivate: [authGuard]
},
// Lazy-loaded protected module
{
path: 'admin',
loadComponent: () => import('./admin/admin.component').then(m => m.AdminComponent),
canActivate: [authGuard]
},
{ path: '', redirectTo: '/dashboard', pathMatch: 'full' },
{ path: '**', component: NotFoundComponent }
];
*/
export {};

74
angular.json Normal file
View file

@ -0,0 +1,74 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm",
"analytics": false
},
"newProjectRoot": "projects",
"projects": {
"KanbanCloneAngular": {
"projectType": "application",
"schematics": {},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.css"
]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "KanbanCloneAngular:build:production"
},
"development": {
"buildTarget": "KanbanCloneAngular:build:development"
}
},
"defaultConfiguration": "development"
},
"test": {
"builder": "@angular/build:unit-test"
}
}
}
}
}

8636
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

43
package.json Normal file
View file

@ -0,0 +1,43 @@
{
"name": "kanban-clone-angular",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"prettier": {
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
},
"private": true,
"packageManager": "npm@11.8.0",
"dependencies": {
"@angular/common": "^21.1.0",
"@angular/compiler": "^21.1.0",
"@angular/core": "^21.1.0",
"@angular/forms": "^21.1.0",
"@angular/platform-browser": "^21.1.0",
"@angular/router": "^21.1.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
},
"devDependencies": {
"@angular/build": "^21.1.3",
"@angular/cli": "^21.1.3",
"@angular/compiler-cli": "^21.1.0",
"jsdom": "^27.1.0",
"typescript": "~5.9.2",
"vitest": "^4.0.8"
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

44
src/app/app.config.ts Normal file
View file

@ -0,0 +1,44 @@
import {
ApplicationConfig,
provideBrowserGlobalErrorListeners,
APP_INITIALIZER,
} from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { catchError, of, throwError } from 'rxjs';
import { routes } from './app.routes';
import { httpInterceptor } from './interceptors/http.interceptor';
import { AuthService } from './services/auth.service';
/**
* Initialize auth state on app startup by checking for existing session
*/
function initializeAuth(authService: AuthService) {
return () =>
authService.checkSession().pipe(
catchError((error) => {
console.error('Session check failed:', error);
if (error?.status === 401 || error?.status === 422) {
authService.clearAuthState();
return of(null);
}
authService.clearAuthState();
return throwError(() => error);
}),
);
}
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideHttpClient(withInterceptors([httpInterceptor])),
{
provide: APP_INITIALIZER,
useFactory: initializeAuth,
deps: [AuthService],
multi: true,
},
],
};

0
src/app/app.css Normal file
View file

5
src/app/app.html Normal file
View file

@ -0,0 +1,5 @@
<app-navbar />
<main class="main">
<router-outlet />
</main>
<app-footer />

64
src/app/app.routes.ts Normal file
View file

@ -0,0 +1,64 @@
import { Routes } from '@angular/router';
import { authGuard } from './guards/auth.guard';
import { LoginComponent } from './pages/login/login.component';
export const routes: Routes = [
// Public routes
{
path: 'login',
component: LoginComponent,
},
{
path: 'register',
loadComponent: () =>
import('./pages/register/register.component').then((m) => m.RegisterComponent),
},
// Protected routes - require authentication
{
path: '',
canActivate: [authGuard],
children: [
{
path: '',
loadComponent: () => import('./pages/home/home.component').then((m) => m.HomeComponent),
},
{
path: 'projects/new',
loadComponent: () =>
import('./pages/project-create/project-create.component').then(
(m) => m.ProjectCreateComponent,
),
},
{
path: 'projects/:id',
loadComponent: () =>
import('./pages/project-details/project-details.component').then(
(m) => m.ProjectDetailsComponent,
),
},
{
path: 'projects/:id/edit',
loadComponent: () =>
import('./pages/project-edit/project-edit.component').then((m) => m.ProjectEditComponent),
},
{
path: 'projects/:id/collaborators/new',
loadComponent: () =>
import('./pages/collaborator-add/collaborator-add.component').then(
(m) => m.CollaboratorAddComponent,
),
},
{
path: 'projects/:id/tasks/new',
loadComponent: () =>
import('./pages/task-create/task-create.component').then((m) => m.TaskCreateComponent),
},
{
path: 'projects/:id/tasks/:taskId/edit',
loadComponent: () =>
import('./pages/task-edit/task-edit.component').then((m) => m.TaskEditComponent),
},
],
},
];

23
src/app/app.spec.ts Normal file
View file

@ -0,0 +1,23 @@
import { TestBed } from '@angular/core/testing';
import { App } from './app';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it('should render title', async () => {
const fixture = TestBed.createComponent(App);
await fixture.whenStable();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, KanbanCloneAngular');
});
});

18
src/app/app.ts Normal file
View file

@ -0,0 +1,18 @@
import { Component, signal, inject } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { AuthService } from './services/auth.service';
import { CommonModule } from '@angular/common';
import { NavbarComponent } from './components/navbar/navbar.component';
import { FooterComponent } from './components/footer/footer.component';
@Component({
selector: 'app-root',
imports: [RouterOutlet, CommonModule, NavbarComponent, FooterComponent],
templateUrl: './app.html',
styleUrl: './app.css'
})
export class App {
protected readonly title = signal('KanbanCloneAngular');
protected readonly description = signal('A simple Kanban board application built with Angular');
protected authService = inject(AuthService);
}

View file

@ -0,0 +1,80 @@
.collaborator-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem;
border-radius: 0.625rem;
background-color: var(--secondary-color);
box-shadow: 0 0.125rem 0.5rem var(--shadow-color);
}
.collaborator-avatar {
width: 2.25rem;
height: 2.25rem;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
background-color: var(--avatar-bg-color);
color: var(--primary-color);
font-weight: 700;
}
.collaborator-info h4 {
margin: 0;
font-size: 1rem;
color: var(--text-color);
}
.collaborator-info p {
margin: 0.125rem 0 0 0;
font-size: 0.8125rem;
color: var(--text-color);
}
.btn-remove {
margin-left: auto;
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
border: 0.0625rem solid var(--danger-border-color);
background: var(--secondary-color);
color: var(--danger-text-color);
font-weight: 700;
cursor: pointer;
transition:
background-color 0.2s ease,
border-color 0.2s ease;
}
.btn-remove:hover {
background-color: var(--danger-bg-color-hover);
border-color: var(--danger-color-hover);
color: var(--danger-text-color);
}
/* Mobile Responsive */
@media (max-width: 48rem) {
.collaborator-item {
padding: 0.75rem;
}
.collaborator-info h4 {
font-size: 0.9375rem;
}
.collaborator-info p {
font-size: 0.75rem;
}
}
@media (max-width: 30rem) {
.collaborator-item {
gap: 0.5rem;
}
.collaborator-avatar {
width: 2rem;
height: 2rem;
}
}

View file

@ -0,0 +1,12 @@
<article class="collaborator-item">
<div class="collaborator-avatar">
{{ user.name.slice(0, 1) || '?' }}
</div>
<div class="collaborator-info">
<h4>{{ user.name }}</h4>
<p>{{ user.email }}</p>
</div>
<button class="btn-remove" type="button" aria-label="Remove collaborator" (click)="onRemove()">
x
</button>
</article>

View file

@ -0,0 +1,19 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { User } from '../../models/auth.models';
@Component({
selector: 'app-collaborator-item',
standalone: true,
imports: [CommonModule],
templateUrl: './collaborator-item.component.html',
styleUrl: './collaborator-item.component.css',
})
export class CollaboratorItemComponent {
@Input({ required: true }) user!: User;
@Output() remove = new EventEmitter<User>();
onRemove() {
this.remove.emit(this.user);
}
}

View file

@ -0,0 +1,34 @@
.footer {
padding: 1.5rem 1rem;
text-align: center;
color: var(--text-color);
background-color: var(--secondary-color);
border-top: 0.0625rem solid var(--shadow-color);
}
.footer p {
margin: 0;
font-size: 0.875rem;
}
/* Mobile Responsive */
@media (max-width: 48rem) {
.footer {
padding: 1.25rem 1rem;
font-size: 0.8125rem;
}
.footer p {
font-size: 0.8125rem;
}
}
@media (max-width: 30rem) {
.footer {
padding: 1rem 0.75rem;
}
.footer p {
font-size: 0.75rem;
}
}

View file

@ -0,0 +1,5 @@
<footer class="footer">
<p>KanbanCloneAngular</p>
<a href="https://www.gnu.org/licenses/agpl-3.0.html"><img src="https://www.gnu.org/graphics/agplv3-88x31.png" alt="AGPLv3 License Logo"></a>
<a href="https://github.com/a-mayb3/KanbanCloneAngular"><img src="" alt=""></a>
</footer>

View file

@ -0,0 +1,11 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-footer',
standalone: true,
imports: [CommonModule],
templateUrl: './footer.component.html',
styleUrl: './footer.component.css'
})
export class FooterComponent {}

View file

@ -0,0 +1,90 @@
.header {
background: var(--secondary-color);
padding: 1.25rem 2.5rem;
box-shadow: 0 0.125rem 0.25rem var(--shadow-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.header h1 {
margin: 0;
font-size: 1.5rem;
color: var(--primary-color);
font-weight: 700;
}
.logo-link {
color: inherit;
text-decoration: none;
}
.logo-link:hover {
text-decoration: underline;
}
.user-info {
display: flex;
align-items: center;
gap: 0.9375rem;
}
.username {
font-weight: 500;
color: var(--text-color);
}
.btn-logout {
padding: 0.5rem 1.25rem;
background-color: var(--primary-color);
color: var(--secondary-color);
border: none;
border-radius: 0.375rem;
cursor: pointer;
font-weight: 500;
transition: background-color 0.3s;
}
.btn-logout:hover {
background-color: var(--primary-color);
}
/* Mobile Responsive */
@media (max-width: 48rem) {
.header {
padding: 1rem 1.5rem;
flex-direction: column;
gap: 1rem;
text-align: center;
}
.header h1 {
font-size: 1.25rem;
}
.user-info {
flex-direction: row;
justify-content: center;
gap: 0.75rem;
width: 100%;
}
.username {
font-size: 0.875rem;
}
.btn-logout {
width: fit-content;
padding: 0.625rem 1rem;
}
}
@media (max-width: 30rem) {
.header {
padding: 0.75rem 1rem;
}
.header h1 {
font-size: 1.125rem;
}
}

View file

@ -0,0 +1,9 @@
<header class="header">
<h1><a class="logo-link" routerLink="/">Kanban Board</a></h1>
<div class="user-info">
<ng-container *ngIf="authService.currentUser()">
<span class="username">{{ authService.currentUser()?.email }}</span>
<button class="btn-logout" (click)="logout()">Logout</button>
</ng-container>
</div>
</header>

View file

@ -0,0 +1,28 @@
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { AuthService } from '../../services/auth.service';
@Component({
selector: 'app-navbar',
standalone: true,
imports: [CommonModule, RouterLink],
templateUrl: './navbar.component.html',
styleUrl: './navbar.component.css'
})
export class NavbarComponent {
protected authService = inject(AuthService);
logout() {
this.authService.logout().subscribe({
next: () => {
console.log('Logout successful');
},
error: (error) => {
console.error('Logout error:', error);
// Even if the API call fails, clear local state and redirect
this.authService.clearAuthState();
}
});
}
}

View file

@ -0,0 +1,161 @@
.project-item {
background: var(--secondary-color);
border-radius: 0.5rem;
padding: 1rem;
box-shadow: 0 0.125rem 0.25rem var(--shadow-color);
cursor: pointer;
transition: all 0.3s ease;
border-left: 0.25rem solid var(--primary-color);
}
.project-item.is-disabled {
cursor: not-allowed;
opacity: 0.7;
}
.project-item:hover {
box-shadow: 0 0.25rem 0.75rem var(--shadow-color);
transform: translateY(-0.125rem);
}
/* Header */
.project-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.75rem;
}
.project-title {
margin: 0;
font-size: 1.375rem;
font-weight: 600;
color: var(--text-color);
flex: 1;
}
.project-actions {
display: flex;
gap: 0.5rem;
opacity: 0;
transition: opacity 0.3s;
}
.project-item:hover .project-actions {
opacity: 1;
}
.btn-icon {
background: none;
border: none;
cursor: pointer;
font-size: 1rem;
padding: 0.25rem;
border-radius: 0.25rem;
transition: background-color 0.2s;
}
.btn-icon:hover {
background-color: var(--background-color);
}
.btn-delete:hover {
background-color: var(--danger-bg-color-hover);
}
/* Description */
.project-description {
margin: 0 0 0.75rem 0;
color: var(--text-color);
font-size: 1rem;
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
}
.completion-row {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
font-size: 0.9375rem;
color: var(--text-color);
width: fit-content;
}
.completion-label {
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 600;
color: var(--text-color);
}
.completion-value {
font-weight: 700;
color: var(--add-color);
}
/* Footer */
.project-footer {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
/* Project ID */
.project-id {
padding: 0.25rem 0.75rem;
border-radius: 0.75rem;
font-size: 0.75rem;
font-weight: 600;
background-color: var(--secondary-color);
color: var(--primary-color);
}
/* Mobile Responsive */
@media (max-width: 48rem) {
.project-item {
padding: 0.875rem;
}
.project-header {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
.project-title {
font-size: 1.25rem;
}
.project-description {
font-size: 0.9375rem;
}
.project-stats {
flex-wrap: wrap;
}
.stat {
font-size: 0.875rem;
}
}
@media (max-width: 30rem) {
.project-item {
padding: 0.75rem;
}
.project-title {
font-size: 1.125rem;
}
.project-id {
font-size: 0.75rem;
}
}

View file

@ -0,0 +1,14 @@
<div class="project-item" [class.is-disabled]="!projectRoute" [routerLink]="projectRoute">
<div class="project-header">
<h3 class="project-title">{{ project.name }}</h3>
</div>
<p class="project-description" *ngIf="project.description">
{{ project.description }}
</p>
<div class="completion-row" *ngIf="completionPercentage != null">
<span class="completion-label">Completion</span>
<span class="completion-value">{{ completionPercentage }}%</span>
</div>
</div>

View file

@ -0,0 +1,43 @@
import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { Project } from '../../models/projects.models';
@Component({
selector: 'app-project-item',
standalone: true,
imports: [CommonModule, RouterLink],
templateUrl: './project-item.component.html',
styleUrl: './project-item.component.css',
})
export class ProjectItemComponent {
@Input({ required: true }) project!: Project;
get completionPercentage(): number | null {
const tasks = this.project.tasks ?? [];
if (tasks.length === 0) {
return null;
}
const completedCount = tasks.filter((task) => task.status === 'completed').length;
return Math.round((completedCount / tasks.length) * 100);
}
get projectRoute(): Array<string | number> | null {
const id = this.getProjectId();
return id == null ? null : ['/projects', id];
}
get projectState(): { project: Project } | null {
return this.projectRoute ? { project: this.project } : null;
}
private getProjectId(): string | number | null {
const projectAsAny = this.project as Project & {
_id?: string | number;
projectId?: string | number;
};
return projectAsAny.id ?? projectAsAny.projectId ?? projectAsAny._id ?? null;
}
}

View file

@ -0,0 +1,169 @@
.task-item {
border: 0.0625rem solid var(--secondary-color);
border-radius: 0.625rem;
padding: 1rem;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 1rem;
background-color: var(--secondary-color);
}
.task-meta {
display: flex;
flex-direction: row;
align-items: center;
flex-wrap: wrap;
gap: 0.375rem;
}
.status-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-color);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.status-select {
padding: 0.375rem 0.625rem;
border-radius: 0.5rem;
border: 0.0625rem solid var(--secondary-color);
background-color: var(--secondary-color);
font-size: 0.75rem;
color: var(--text-color);
width: fit-content;
height: 2rem;
text-align: center;
}
.btn-remove {
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
border: 0.0625rem solid var(--danger-border-color);
background: var(--secondary-color);
color: var(--danger-text-color);
font-weight: 700;
cursor: pointer;
transition:
background-color 0.2s ease,
border-color 0.2s ease;
}
.btn-remove:hover {
background-color: var(--danger-bg-color-hover);
border-color: var(--danger-color-hover);
color: var(--danger-text-color);
}
.btn-edit {
height: 2rem;
padding: 0 0.75rem;
border-radius: 0.5rem;
border: 0.0625rem solid var(--secondary-color);
background: var(--secondary-color);
color: var(--text-color);
font-weight: 600;
font-size: 0.75rem;
cursor: pointer;
transition:
background-color 0.2s ease,
border-color 0.2s ease;
}
.btn-edit:hover {
background-color: var(--background-color);
border-color: var(--primary-color);
}
.task-info h4 {
margin: 0 0 0.375rem 0;
color: var(--text-color);
}
.task-info p {
margin: 0;
color: var(--text-color);
font-size: 0.875rem;
}
.task-info {
flex: 1;
}
.status {
align-self: flex-start;
padding: 0.25rem 0.625rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.status-pending {
background-color: var(--secondary-color);
color: var(--text-color);
}
.status-in_progress {
background-color: var(--primary-color);
color: var(--secondary-color);
}
.status-completed {
background-color: var(--add-color);
color: var(--secondary-color);
}
.status-stashed {
background-color: var(--background-color);
color: var(--text-color);
}
.status-failed {
background-color: var(--danger-bg-color);
color: var(--danger-text-color);
}
/* Mobile Responsive */
@media (max-width: 48rem) {
.task-item {
padding: 0.875rem;
}
.task-header h3 {
font-size: 0.9375rem;
}
.task-description {
font-size: 0.8125rem;
}
.task-footer {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
.task-actions {
width: 100%;
justify-content: space-between;
}
}
@media (max-width: 30rem) {
.task-item {
padding: 0.75rem;
}
.task-header h3 {
font-size: 0.875rem;
}
.status-badge {
font-size: 0.625rem;
}
}

View file

@ -0,0 +1,23 @@
<article class="task-item">
<div class="task-info">
<h4>{{ task.title }}</h4>
<p>{{ task.description || 'No description.' }}</p>
</div>
<div class="task-meta">
<label class="status-label" for="status-{{ task.id }}">Status</label>
<select
id="status-{{ task.id }}"
class="status-select"
[(ngModel)]="statusValue"
(ngModelChange)="onStatusChange($event)"
>
<option *ngFor="let status of statusOptions; trackBy: trackByStatus" [value]="status">
{{ status.replace('_', ' ').toUpperCase() }}
</option>
</select>
<button class="btn-edit" type="button" aria-label="Edit task" (click)="onEdit()">Edit</button>
<button class="btn-remove" type="button" aria-label="Remove task" (click)="onRemove()">
x
</button>
</div>
</article>

View file

@ -0,0 +1,81 @@
import { Component, EventEmitter, Input, Output, inject } from '@angular/core';
import { Router } from '@angular/router';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Task } from '../../models/tasks.models';
@Component({
selector: 'app-task-item',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './task-item.component.html',
styleUrl: './task-item.component.css',
})
export class TaskItemComponent {
private router = inject(Router);
private _task!: Task;
statusValue: Task['status'] = 'pending';
@Input({ required: true })
set task(value: Task) {
this._task = value;
this.statusValue = value?.status ?? 'pending';
}
get task(): Task {
return this._task;
}
@Input()
projectId?: number;
@Output() statusChange = new EventEmitter<{
task: Task;
status: Task['status'];
previousStatus: Task['status'];
}>();
@Output() remove = new EventEmitter<Task>();
readonly statusOptions: Task['status'][] = [
'pending',
'in_progress',
'completed',
'stashed',
'failed',
];
trackByStatus(_index: number, status: Task['status']): Task['status'] {
return status;
}
onStatusChange(value: Task['status']) {
const previousStatus = this.statusValue;
const nextStatus = value;
this.statusValue = nextStatus;
if (this._task) {
this._task.status = nextStatus;
this.statusChange.emit({
task: this._task,
status: nextStatus,
previousStatus,
});
}
}
onRemove() {
if (this._task) {
this.remove.emit(this._task);
}
}
onEdit() {
if (!this._task || this.projectId == null) {
return;
}
this.router.navigate(['/projects', this.projectId, 'tasks', this._task.id, 'edit'], {
state: { task: this._task },
});
}
}

View file

@ -0,0 +1,8 @@
/**
* Environment configuration
* Update these values based on your environment (development, production, etc.)
*/
export const environment = {
production: false,
apiBaseUrl: 'http://localhost:8000',
};

View file

@ -0,0 +1,19 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router, UrlTree } from '@angular/router';
import { AuthService } from '../services/auth.service';
/**
* Auth guard to protect routes that require authentication
* Usage: Add to route definition with canActivate: [authGuard]
*/
export const authGuard: CanActivateFn = (): boolean | UrlTree => {
const authService = inject(AuthService);
const router = inject(Router);
if (authService.isAuthenticated()) {
return true;
} else {
// Redirect to login page
return router.createUrlTree(['/login']);
}
};

View file

@ -0,0 +1,38 @@
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { catchError, throwError } from 'rxjs';
/**
* HTTP Interceptor that:
* - Adds withCredentials to all requests (enables cookie sending/receiving)
* - Handles global HTTP errors
* - Redirects to login on 401 Unauthorized
*/
export const httpInterceptor: HttpInterceptorFn = (req, next) => {
const router = inject(Router);
// Clone the request to add withCredentials flag
// This ensures cookies are sent with every request
const reqWithCredentials = req.clone({
withCredentials: true,
});
// Pass the cloned request to the next handler
return next(reqWithCredentials).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === 401) {
if (router.url !== '/login') {
router.navigate(['/login']);
}
} else if (error.status === 403) {
console.error('Access forbidden:', error.message);
} else {
console.error(`HTTP Error ${error.status}:`, error.message);
}
// Re-throw the error so components can handle it if needed
return throwError(() => error);
}),
);
};

View file

@ -0,0 +1,45 @@
/**
* Authentication-related type definitions
*/
import { Project } from './projects.models';
export interface LoginRequest {
email: string;
password: string;
}
export interface LoginResponse {
success: boolean;
message?: string;
user?: LoginUserResponse;
}
export interface LoginUserResponse {
id: number;
}
export interface RegisterRequest {
name: string;
email: string;
password: string;
}
export interface RegisterResponse {
success?: boolean;
message?: string;
user?: User;
}
export interface User {
id: string | number;
name: string;
email: string;
projects?: Project[]; // Add project type if available
// Add other user properties as needed
}
export interface AuthState {
isAuthenticated: boolean;
user: User | null;
}

View file

@ -0,0 +1,36 @@
import { User } from './auth.models';
import { Task } from './tasks.models';
export interface Project {
id: number;
name: string;
description?: string;
tasks?: Task[];
}
export interface ProjectFull {
id: number;
name: string;
description?: string;
tasks: Task[];
users: User[];
}
export interface CreateProjectRequest {
name: string;
description: string;
}
export interface UpdateProjectRequest {
name?: string;
description?: string;
}
export interface AddCollaboratorRequest {
user_email: string;
}
export interface AddCollaboratorResponse {
success?: boolean;
message?: string;
}

View file

@ -0,0 +1,18 @@
export interface Task {
id: number;
title: string;
description?: string;
status: 'pending' | 'in_progress' | 'completed' | 'stashed' | 'failed';
}
export interface CreateTaskRequest {
title: string;
description?: string;
status: Task['status'];
}
export interface UpdateTaskRequest {
title: string;
description?: string;
status: Task['status'];
}

View file

@ -0,0 +1,127 @@
.collaborator-add {
min-height: 100vh;
background-color: var(--background-color);
padding: 2.5rem;
display: flex;
justify-content: center;
align-items: flex-start;
}
.card {
width: 100%;
max-width: 32.5rem;
height: fit-content;
background: var(--secondary-color);
border-radius: 0.75rem;
padding: 2rem;
box-shadow: 0 0.125rem 0.5rem var(--shadow-color);
}
.card h1 {
margin: 0 0 1.5rem 0;
font-size: 1.75rem;
color: var(--text-color);
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: var(--text-color);
}
.form-group input {
width: 100%;
padding: 0.75rem 0.875rem;
border: 0.0625rem solid var(--secondary-color);
border-radius: 0.5rem;
font-size: 0.875rem;
box-sizing: border-box;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 0.75rem;
}
.btn-primary {
padding: 0.625rem 1rem;
background-color: var(--add-color);
color: var(--secondary-color);
border: none;
border-radius: 0.5rem;
font-weight: 600;
cursor: pointer;
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-secondary {
padding: 0.625rem 1rem;
background: none;
border: 0.0625rem solid var(--secondary-color);
color: var(--text-color);
border-radius: 0.5rem;
font-weight: 600;
cursor: pointer;
}
.error {
margin-bottom: 1rem;
padding: 0.75rem;
border-radius: 0.5rem;
background-color: var(--danger-bg-color);
color: var(--danger-text-color);
}
/* Mobile Responsive */
@media (max-width: 48rem) {
.collaborator-add {
padding: 1.5rem 1rem;
}
.card {
padding: 1.5rem;
}
.card h1 {
font-size: 1.5rem;
margin-bottom: 1.25rem;
}
.actions {
flex-direction: column-reverse;
}
.btn-primary,
.btn-secondary {
width: 100%;
}
}
@media (max-width: 30rem) {
.collaborator-add {
padding: 1rem 0.75rem;
}
.card {
padding: 1.25rem;
}
.card h1 {
font-size: 1.375rem;
}
.form-group input {
font-size: 0.8125rem;
}
}

View file

@ -0,0 +1,34 @@
<div class="collaborator-add">
<div class="card">
<h1>Add collaborator</h1>
<div class="error" *ngIf="errorMessage()">{{ errorMessage() }}</div>
<form (ngSubmit)="onSubmit()" #collaboratorForm="ngForm">
<div class="form-group">
<label for="email">Collaborator e-mail</label>
<input
id="email"
type="email"
name="email"
[(ngModel)]="email"
placeholder="Enter collaborator e-mail"
required
autocomplete="email"
/>
</div>
<div class="actions">
<button type="button" class="btn-secondary" (click)="onCancel()">Cancel</button>
<button
type="submit"
class="btn-primary"
[disabled]="isSaving() || !collaboratorForm.form.valid"
>
<span *ngIf="isSaving(); else addText">Adding...</span>
<ng-template #addText><span>Add collaborator</span></ng-template>
</button>
</div>
</form>
</div>
</div>

View file

@ -0,0 +1,76 @@
import { Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { ApiService } from '../../services/api.service';
import { AddCollaboratorRequest, AddCollaboratorResponse } from '../../models/projects.models';
@Component({
selector: 'app-collaborator-add',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './collaborator-add.component.html',
styleUrl: './collaborator-add.component.css',
})
export class CollaboratorAddComponent {
private apiService = inject(ApiService);
private route = inject(ActivatedRoute);
private router = inject(Router);
email = '';
isSaving = signal(false);
errorMessage = signal('');
private projectId: number | null = null;
constructor() {
const idParam = this.route.snapshot.paramMap.get('id');
const projectId = idParam ? Number(idParam) : Number.NaN;
if (!Number.isFinite(projectId)) {
this.errorMessage.set('Invalid project id.');
return;
}
this.projectId = projectId;
}
onSubmit() {
if (!this.email.trim()) {
this.errorMessage.set('Collaborator email is required.');
return;
}
if (this.projectId == null) {
this.errorMessage.set('Invalid project id.');
return;
}
const payload: AddCollaboratorRequest = {
user_email: this.email.trim(),
};
this.isSaving.set(true);
this.errorMessage.set('');
this.apiService
.post<AddCollaboratorResponse>(`/projects/${this.projectId}/users/`, payload)
.subscribe({
next: () => {
this.isSaving.set(false);
this.router.navigate(['/projects', this.projectId]);
},
error: (error) => {
this.isSaving.set(false);
this.errorMessage.set(error?.error?.message || 'Failed to add collaborator.');
},
});
}
onCancel() {
if (this.projectId != null) {
this.router.navigate(['/projects', this.projectId]);
} else {
this.router.navigate(['/']);
}
}
}

View file

@ -0,0 +1,150 @@
.home-container {
min-height: 100vh;
background-color: var(--background-color);
}
.content {
padding: 2.5rem;
max-width: 87.5rem;
margin: 0 auto;
}
.welcome-message {
background: var(--secondary-color);
padding: 2.5rem;
border-radius: 0.75rem;
box-shadow: 0 0.125rem 0.5rem var(--shadow-color);
text-align: center;
margin-bottom: 1.875rem;
}
.welcome-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.welcome-message h2 {
margin: 0 0 0.625rem 0;
color: var(--text-color);
font-size: 1.75rem;
}
.btn-create {
padding: 0.625rem 1.125rem;
background-color: var(--add-color);
color: var(--secondary-color);
border: none;
border-radius: 0.5rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s ease;
}
.btn-create:hover {
background-color: var(--add-color-hover);
}
.welcome-message p {
margin: 0;
color: var(--text-color);
font-size: 1rem;
}
/* Projects List */
.projects-grid {
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
}
.loading,
.error-state,
.loading-state {
background: var(--secondary-color);
padding: 1.875rem 1.5rem;
border-radius: 0.75rem;
box-shadow: 0 0.125rem 0.5rem var(--shadow-color);
text-align: center;
}
.error-state p {
color: var(--danger-text-color);
margin: 0;
}
.loading p {
color: var(--text-color);
margin: 0;
}
.no-projects {
background: var(--secondary-color);
padding: 3.75rem 2.5rem;
border-radius: 0.75rem;
box-shadow: 0 0.125rem 0.5rem var(--shadow-color);
text-align: center;
}
.no-projects p {
margin: 0;
color: var(--text-color);
font-size: 1rem;
}
.create-link {
color: var(--add-color);
font-weight: 600;
text-decoration: underline;
text-underline-offset: 0.1875rem;
}
/* Mobile Responsive */
@media (max-width: 48rem) {
.content {
padding: 1.5rem 1rem;
}
.welcome-message {
padding: 1.5rem;
}
.welcome-message h2 {
font-size: 1.375rem;
}
.welcome-header {
flex-direction: column;
align-items: stretch;
}
.btn-create {
width: 100%;
}
}
@media (max-width: 30rem) {
.content {
padding: 1rem 0.75rem;
}
.welcome-message {
padding: 1.25rem;
margin-bottom: 1.25rem;
}
.welcome-message h2 {
font-size: 1.25rem;
}
.welcome-message p {
font-size: 0.875rem;
}
}
.create-link:hover {
color: var(--add-color-hover);
}

View file

@ -0,0 +1,30 @@
<div class="home-container">
<main class="content">
<div class="welcome-message">
<div class="welcome-header">
<h2>Welcome, {{ authService.currentUser()?.name }}! 👋</h2>
<button class="btn-create" (click)="onCreateProject()">Create project</button>
</div>
</div>
<div class="projects-grid">
<ng-container *ngIf="projects.length > 0; else noProjects">
<app-project-item
*ngFor="let project of projects; trackBy: trackByProjectId"
[project]="project"
/>
</ng-container>
<ng-template #noProjects>
<div class="no-projects">
<p>
You have no projects.
<a class="create-link" href="#" (click)="onCreateProject(); $event.preventDefault()">
Create one now
</a>
</p>
</div>
</ng-template>
</div>
</main>
</div>

View file

@ -0,0 +1,33 @@
import { Component, inject, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { AuthService } from '../../services/auth.service';
import { ProjectItemComponent } from '../../components/project-item/project-item.component';
import { Project } from '../../models/projects.models';
@Component({
selector: 'app-home',
standalone: true,
imports: [CommonModule, ProjectItemComponent],
templateUrl: './home.component.html',
styleUrl: './home.component.css',
})
export class HomeComponent implements OnInit {
protected authService = inject(AuthService);
private router = inject(Router);
protected projects: Project[] = [];
ngOnInit(): void {
this.projects = this.authService.currentUser()?.projects ?? [];
}
onCreateProject() {
this.router.navigate(['/projects/new']);
}
trackByProjectId(_index: number, project: Project): number {
return project.id;
}
}

View file

@ -0,0 +1,164 @@
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
padding: 1.25rem;
}
.login-card {
background: var(--secondary-color);
border-radius: 0.75rem;
padding: 2.5rem;
box-shadow: 0 0.625rem 2.5rem var(--shadow-color);
width: 100%;
max-width: 25rem;
}
.login-card h1 {
font-size: 1.5rem;
font-weight: 700;
color: var(--primary-color);
margin: 0 0 0.625rem 0;
text-align: center;
}
.login-card h2 {
font-size: 1.75rem;
font-weight: 600;
color: var(--text-color);
margin: 0 0 1.875rem 0;
text-align: center;
}
.error-message {
background-color: var(--danger-bg-color);
color: var(--danger-text-color);
padding: 0.75rem;
border-radius: 0.375rem;
margin-bottom: 1.25rem;
border: 0.0625rem solid var(--danger-border-color);
font-size: 0.875rem;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: var(--text-color);
font-size: 0.875rem;
}
.form-group input {
width: 100%;
padding: 0.75rem 1rem;
border: 0.125rem solid var(--secondary-color);
border-radius: 0.5rem;
font-size: 0.9375rem;
transition: border-color 0.3s;
box-sizing: border-box;
}
.form-group input:focus {
outline: none;
border-color: var(--primary-color);
}
.form-group input::placeholder {
color: var(--text-color);
}
.btn-primary {
width: 100%;
padding: 0.875rem;
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
color: var(--secondary-color);
border: none;
border-radius: 0.5rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition:
transform 0.2s,
box-shadow 0.2s;
margin-top: 0.625rem;
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-0.125rem);
box-shadow: 0 0.3125rem 1.25rem var(--shadow-color);
}
.btn-primary:active:not(:disabled) {
transform: translateY(0);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.register-link {
margin: 1rem 0 0 0;
text-align: center;
font-size: 0.875rem;
color: var(--text-color);
}
/* Mobile Responsive */
@media (max-width: 48rem) {
.login-container {
padding: 1rem;
}
.login-card {
padding: 2rem 1.5rem;
}
.login-card h1 {
font-size: 1.25rem;
}
.login-card h2 {
font-size: 1.5rem;
margin-bottom: 1.5rem;
}
}
@media (max-width: 30rem) {
.login-container {
padding: 0.75rem;
}
.login-card {
padding: 1.5rem 1rem;
}
.login-card h1 {
font-size: 1.125rem;
}
.login-card h2 {
font-size: 1.375rem;
}
.form-group input {
padding: 0.625rem 0.875rem;
font-size: 0.875rem;
}
}
.register-link a {
color: var(--primary-color);
font-weight: 600;
text-decoration: none;
}
.register-link a:hover {
text-decoration: underline;
}

View file

@ -0,0 +1,45 @@
<div class="login-container">
<div class="login-card">
<h1>{{ 'KanbanCloneAngular' }}</h1>
<h2>Sign In</h2>
<div class="error-message" *ngIf="errorMessage()">
{{ errorMessage() }}
</div>
<form (ngSubmit)="onSubmit()" #loginForm="ngForm">
<div class="form-group">
<label for="email">e-mail address</label>
<input
id="email"
type="email"
[(ngModel)]="email"
name="email"
placeholder="Enter your e-mail address"
required
autocomplete="username"
/>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
id="password"
type="password"
[(ngModel)]="password"
name="password"
placeholder="Enter your password"
required
autocomplete="current-password"
/>
</div>
<button type="submit" class="btn-primary" [disabled]="isLoading() || !loginForm.form.valid">
<span *ngIf="isLoading(); else loginText">Logging in...</span>
<ng-template #loginText><span>Login</span></ng-template>
</button>
</form>
<p class="register-link">or <a routerLink="/register">register here</a></p>
</div>
</div>

View file

@ -0,0 +1,83 @@
import { Component, inject, signal, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import { CommonModule } from '@angular/common';
import { AuthService } from '../../services/auth.service';
import { catchError, of, throwError } from 'rxjs';
@Component({
selector: 'app-login',
standalone: true,
imports: [FormsModule, CommonModule, RouterLink],
templateUrl: './login.component.html',
styleUrl: './login.component.css',
})
export class LoginComponent implements OnInit {
private authService = inject(AuthService);
private router = inject(Router);
ngOnInit() {
if (this.authService.isAuthenticated()) {
this.router.navigate(['/']);
return;
}
this.authService
.checkSession()
.pipe(
catchError((error) => {
if (error?.status === 401 || error?.status === 422) {
return of(null);
}
return throwError(() => error);
}),
)
.subscribe({
next: (user) => {
if (user) {
this.router.navigate(['/']);
}
},
error: (error) => {
this.errorMessage.set(error?.error?.message || 'Session check failed.');
},
});
}
email = '';
password = '';
isLoading = signal(false);
errorMessage = signal('');
onSubmit() {
if (!this.email || !this.password) {
this.errorMessage.set('Please fill in all fields');
return;
}
this.isLoading.set(true);
this.errorMessage.set('');
this.authService
.login({
email: this.email,
password: this.password,
})
.subscribe({
next: (response) => {
this.isLoading.set(false);
if (response.success || response.user) {
this.router.navigate(['/']);
} else {
this.errorMessage.set(response.message || 'Login failed');
}
},
error: (error) => {
this.isLoading.set(false);
this.errorMessage.set(
error.error?.message || 'Login failed. Please check your credentials.',
);
},
});
}
}

View file

@ -0,0 +1,133 @@
.create-project {
min-height: 100vh;
background-color: var(--background-color);
padding: 2.5rem;
display: flex;
justify-content: center;
align-items: flex-start;
}
.card {
width: 100%;
max-width: 40rem;
height: fit-content;
background: var(--secondary-color);
border-radius: 0.75rem;
padding: 2rem;
box-shadow: 0 0.125rem 0.5rem var(--shadow-color);
}
.card h1 {
margin: 0 0 1.5rem 0;
font-size: 1.75rem;
color: var(--text-color);
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: var(--text-color);
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 0.75rem 0.875rem;
border: 0.0625rem solid var(--secondary-color);
border-radius: 0.5rem;
font-size: 0.875rem;
box-sizing: border-box;
}
.form-group textarea {
resize: vertical;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 0.75rem;
}
.btn-primary {
padding: 0.625rem 1rem;
background-color: var(--add-color);
color: var(--secondary-color);
border: none;
border-radius: 0.5rem;
font-weight: 600;
cursor: pointer;
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-secondary {
padding: 0.625rem 1rem;
background: none;
border: 0.0625rem solid var(--secondary-color);
color: var(--text-color);
border-radius: 0.5rem;
font-weight: 600;
cursor: pointer;
}
.error {
margin-bottom: 1rem;
padding: 0.75rem;
border-radius: 0.5rem;
background-color: var(--danger-bg-color);
color: var(--danger-text-color);
}
/* Mobile Responsive */
@media (max-width: 48rem) {
.create-project {
padding: 1.5rem 1rem;
}
.card {
padding: 1.5rem;
}
.card h1 {
font-size: 1.5rem;
margin-bottom: 1.25rem;
}
.actions {
flex-direction: column-reverse;
}
.btn-primary,
.btn-secondary {
width: 100%;
}
}
@media (max-width: 30rem) {
.create-project {
padding: 1rem 0.75rem;
}
.card {
padding: 1.25rem;
}
.card h1 {
font-size: 1.375rem;
}
.form-group input,
.form-group textarea {
font-size: 0.8125rem;
}
}

View file

@ -0,0 +1,45 @@
<div class="create-project">
<div class="card">
<h1>Create project</h1>
<div class="error" *ngIf="errorMessage()">{{ errorMessage() }}</div>
<form (ngSubmit)="onSubmit()" #projectForm="ngForm">
<div class="form-group">
<label for="name">Project name</label>
<input
id="name"
type="text"
name="name"
[(ngModel)]="name"
placeholder="Enter a project name"
required
/>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea
id="description"
name="description"
[(ngModel)]="description"
rows="4"
placeholder="Enter a project description"
required
></textarea>
</div>
<div class="actions">
<button type="button" class="btn-secondary" (click)="onCancel()">Cancel</button>
<button
type="submit"
class="btn-primary"
[disabled]="isSaving() || !projectForm.form.valid"
>
<span *ngIf="isSaving(); else createText">Creating...</span>
<ng-template #createText><span>Create project</span></ng-template>
</button>
</div>
</form>
</div>
</div>

View file

@ -0,0 +1,64 @@
import { Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { ApiService } from '../../services/api.service';
import { CreateProjectRequest, Project } from '../../models/projects.models';
@Component({
selector: 'app-project-create',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './project-create.component.html',
styleUrl: './project-create.component.css',
})
export class ProjectCreateComponent {
private apiService = inject(ApiService);
private router = inject(Router);
name = '';
description = '';
isSaving = signal(false);
errorMessage = signal('');
onSubmit() {
if (!this.name.trim()) {
this.errorMessage.set('Project name is required.');
return;
}
if (!this.description.trim()) {
this.errorMessage.set('Project description is required.');
return;
}
const payload: CreateProjectRequest = {
name: this.name.trim(),
description: this.description.trim(),
};
this.isSaving.set(true);
this.errorMessage.set('');
this.apiService.post<Project>('/projects', payload).subscribe({
next: (project) => {
this.isSaving.set(false);
if (project?.id != null) {
this.router.navigate(['/projects/', project.id]);
} else {
this.router.navigate(['/']);
}
},
error: (error) => {
this.isSaving.set(false);
this.errorMessage.set(
error?.error?.message || 'Failed to create project. Please try again.',
);
},
});
}
onCancel() {
this.router.navigate(['/']);
}
}

View file

@ -0,0 +1,336 @@
.content {
padding: 2.5rem;
width: clamp(70vw, 85vw, 90vw);
max-width: 80%;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.title h2 {
margin: 0 0 0.375rem 0;
font-size: 1.625rem;
color: var(--text-color);
}
.title p {
margin: 0;
color: var(--text-color);
}
.project-card,
.collaborators-card,
.tasks-card,
.danger-zone,
.loading,
.error-state,
.empty-state {
height: fit-content;
background: var(--secondary-color);
padding: 1rem;
border-radius: 0.75rem;
box-shadow: 0 0.125rem 0.5rem var(--shadow-color);
}
.project-card h2 {
margin: 0;
padding-top: 0.5rem;
}
.danger-zone {
border: 0.0625rem solid var(--danger-border-color);
background: var(--danger-bg-color);
}
.danger-header h3 {
margin: 0 0 0.375rem 0;
color: var(--danger-text-color);
}
.danger-header p {
margin: 0;
color: var(--danger-text-color);
}
.danger-actions {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-top: 1rem;
}
.danger-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem;
border-radius: 0.625rem;
background: var(--secondary-color);
border: 0.0625rem solid var(--danger-border-color);
}
.danger-item h4 {
margin: 0 0 0.25rem 0;
color: var(--danger-text-color);
font-size: 1rem;
}
.danger-item p {
margin: 0;
color: var(--danger-text-color);
font-size: 0.8125rem;
}
.btn-danger,
.btn-danger-outline {
padding: 0.5rem 0.875rem;
border-radius: 0.5rem;
font-weight: 600;
cursor: pointer;
transition:
background-color 0.2s ease,
color 0.2s ease,
border-color 0.2s ease;
white-space: nowrap;
}
.btn-danger {
border: none;
background-color: var(--danger-color);
color: var(--secondary-color);
}
.btn-danger:hover {
background-color: var(--danger-color-hover);
}
.btn-danger-outline {
border: 0.0625rem solid var(--danger-border-color);
background-color: var(--secondary-color);
color: var(--danger-text-color);
}
.btn-danger-outline:hover {
border-color: var(--danger-color-hover);
background-color: var(--danger-bg-color-hover);
}
.description {
margin: 0;
color: var(--text-color);
}
.tasks-header,
.collaborators-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding-top: 0.5rem;
padding-bottom: 1.5rem;
}
.tasks-header h4,
.collaborators-header h4 {
margin: 0;
}
.collaborators-count {
font-size: 0.875rem;
color: var(--text-color);
}
.collaborators-grid {
display: flex;
flex-direction: column;
gap: 1rem;
}
.collaborators-grid--single {
flex-direction: column;
}
.btn-add-task {
width: 100%;
padding: 0.5rem 0.875rem;
border-radius: 0.5rem;
border: none;
background-color: var(--add-color);
color: var(--secondary-color);
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s ease;
margin-top: 1rem;
}
.btn-add-collaborator {
width: 100%;
padding: 0.5rem 0.875rem;
border-radius: 0.5rem;
border: none;
background-color: var(--add-color);
color: var(--secondary-color);
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s ease;
margin-top: 1rem;
}
.btn-add-task:hover,
.btn-add-collaborator:hover {
background-color: var(--add-color-hover);
}
.tasks-count {
font-size: 0.875rem;
color: var(--text-color);
}
.tasks-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
flex: 1;
}
.cards-row {
width: 100%;
height: max-content;
display: flex;
flex-direction: row;
gap: 1rem;
align-items: stretch;
}
.cards-row section {
height: auto;
width: 100%;
}
.cards-row .tasks-card {
display: flex;
flex-direction: column;
flex: 3;
}
.cards-row .collaborators-card {
flex: 1;
}
.loading p,
.error-state p,
.empty-state p {
margin: 0;
color: var(--text-color);
}
.error-state p {
color: var(--danger-text-color);
}
@media (max-width: 60rem) {
.content {
width: 90vw;
padding: 1.5rem 1rem;
}
.cards-row {
flex-direction: column;
}
.title h2 {
font-size: 1.375rem;
}
}
@media (max-width: 48rem) {
.content {
width: 95%;
max-width: 100%;
padding: 1.25rem 1rem;
gap: 0.75rem;
margin: 0 auto;
}
.title h2 {
font-size: 1.25rem;
}
.project-card,
.collaborators-card,
.tasks-card,
.danger-zone {
padding: 0.875rem;
}
.tasks-header,
.collaborators-header {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
padding-bottom: 1rem;
}
.tasks-header h4,
.collaborators-header h4 {
width: 100%;
font-size: 3rem;
text-align: center;
}
.btn-add-task,
.btn-add-collaborator {
width: 100%;
}
.danger-item {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
.btn-danger,
.btn-danger-outline {
width: 100%;
}
}
@media (max-width: 37.5rem) {
.content {
width: 95%;
max-width: 100%;
padding: 1rem 0.75rem;
margin: 0 auto;
}
.title h2 {
font-size: 1.125rem;
}
.title p,
.description {
font-size: 0.875rem;
}
.project-card,
.collaborators-card,
.tasks-card,
.danger-zone {
padding: 0.75rem;
}
.tasks-header h4,
.collaborators-header h4 {
font-size: 1.125rem;
}
.danger-item h4 {
font-size: 1.0625rem;
}
.danger-item p {
font-size: 0.75rem;
}
}

View file

@ -0,0 +1,112 @@
<div class="details-container">
<main class="content">
<ng-container *ngIf="isLoading(); else loadedState">
<div class="loading">
<p>Loading project...</p>
</div>
</ng-container>
<ng-template #loadedState>
<ng-container *ngIf="errorMessage(); else contentState">
<div class="error-state">
<p>{{ errorMessage() }}</p>
</div>
</ng-container>
</ng-template>
<ng-template #contentState>
<section class="project-card">
<h2>{{ project()?.name }}</h2>
<h4>Project description:</h4>
<p class="description">
{{ project()?.description || 'No description available.' }}
</p>
</section>
<div class="cards-row">
<section class="tasks-card">
<div class="tasks-header">
<h4>Tasks</h4>
<span class="tasks-count">
{{ project()?.tasks?.length ?? 0 }} total • {{ taskCompletionPercentage }}% completed
</span>
</div>
<ng-container *ngIf="(project()?.tasks?.length ?? 0) > 0; else emptyTasks">
<div class="tasks-list">
<app-task-item
*ngFor="let task of project()?.tasks ?? []; trackBy: trackByTaskId"
[task]="task"
[projectId]="project()?.id"
(statusChange)="onTaskStatusChange($event)"
/>
</div>
</ng-container>
<ng-template #emptyTasks>
<div class="empty-state">
<p>No tasks yet.</p>
</div>
</ng-template>
<button class="btn-add-task" type="button" (click)="onAddTask()">Add task</button>
</section>
<section class="collaborators-card">
<div class="collaborators-header">
<h4>Collaborators</h4>
<span class="collaborators-count"> {{ project()?.users?.length ?? 0 }} total </span>
</div>
<ng-container *ngIf="(project()?.users?.length ?? 0) > 0; else emptyCollaborators">
<div
class="collaborators-grid"
[class.collaborators-grid--single]="(project()?.users?.length ?? 0) === 1"
>
<app-collaborator-item
*ngFor="let user of project()?.users ?? []; trackBy: trackByUserId"
[user]="user"
(remove)="onRemoveCollaborator($event)"
/>
</div>
</ng-container>
<ng-template #emptyCollaborators>
<div class="empty-state">
<p>No collaborators yet.</p>
</div>
</ng-template>
<button class="btn-add-collaborator" type="button" (click)="onAddCollaborator()">
Add collaborator
</button>
</section>
</div>
<section class="danger-zone">
<div class="danger-header">
<h3>Danger zone</h3>
<p>Actions that affect this project permanently.</p>
</div>
<div class="danger-actions">
<div class="danger-item">
<div>
<h4>Edit project details</h4>
<p>Update the project name or description.</p>
</div>
<button class="btn-danger-outline" type="button" (click)="onEditProject()">
Edit project
</button>
</div>
<div class="danger-item">
<div>
<h4>Delete project</h4>
<p>This will remove the project and all associated data.</p>
</div>
<button class="btn-danger" type="button" (click)="onDeleteProject()">
Delete project
</button>
</div>
</div>
</section>
</ng-template>
</main>
</div>

View file

@ -0,0 +1,197 @@
import { Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { ApiService } from '../../services/api.service';
import { ProjectFull } from '../../models/projects.models';
import { CollaboratorItemComponent } from '../../components/collaborator-item/collaborator-item.component';
import { TaskItemComponent } from '../../components/task-item/task-item.component';
import { User } from '../../models/auth.models';
import { Task } from '../../models/tasks.models';
@Component({
selector: 'app-project-details',
standalone: true,
imports: [CommonModule, CollaboratorItemComponent, TaskItemComponent],
templateUrl: './project-details.component.html',
styleUrl: './project-details.component.css',
})
export class ProjectDetailsComponent {
private apiService = inject(ApiService);
private route = inject(ActivatedRoute);
private router = inject(Router);
private projectId: number | null = null;
project = signal<ProjectFull | null>(null);
isLoading = signal(true);
errorMessage = signal('');
get taskCompletionPercentage(): number {
const tasks = this.project()?.tasks ?? [];
if (tasks.length === 0) {
return 0;
}
const completedCount = tasks.filter((task) => task.status === 'completed').length;
return Math.round((completedCount / tasks.length) * 100);
}
constructor() {
const navState = this.router.getCurrentNavigation()?.extras.state ?? (history.state as any);
const initialProject = navState?.project as ProjectFull | undefined;
if (initialProject) {
this.project.set(initialProject);
this.isLoading.set(false);
}
const idParam = this.route.snapshot.paramMap.get('id');
const projectId = idParam ? Number(idParam) : Number.NaN;
if (!Number.isFinite(projectId)) {
this.isLoading.set(false);
this.errorMessage.set('Invalid project id.');
return;
}
this.projectId = projectId;
this.apiService.get<ProjectFull>(`/projects/${projectId}`).subscribe({
next: (project) => {
this.project.set(project);
this.isLoading.set(false);
},
error: (error) => {
this.isLoading.set(false);
if (!initialProject) {
this.errorMessage.set(error?.error?.message || 'Failed to load project details.');
}
},
});
}
onBack() {
this.router.navigate(['/']);
}
onRemoveCollaborator(user: User) {
const current = this.project();
if (!current || this.projectId == null) {
return;
}
const targetId = this.getUserId(user);
if (!targetId) {
return;
}
const previousUsers = current.users ?? [];
const updatedUsers = (current.users ?? []).filter(
(collaborator) => this.getUserId(collaborator) !== targetId,
);
this.project.set({
...current,
users: updatedUsers,
});
this.apiService.delete<void>(`/projects/${this.projectId}/users/${targetId}`).subscribe({
error: (error) => {
this.project.set({
...current,
users: previousUsers,
});
this.errorMessage.set(error?.error?.message || 'Failed to remove collaborator.');
},
});
}
private getUserId(user: User): string {
return String(user.id ?? '');
}
onAddTask() {
if (this.projectId == null) {
return;
}
this.router.navigate(['/projects', this.projectId, 'tasks', 'new']);
}
onTaskStatusChange(event: {
task: Task;
status: Task['status'];
previousStatus: Task['status'];
}) {
if (this.projectId == null) {
return;
}
this.apiService
.put<void>(`/projects/${this.projectId}/tasks/${event.task.id}`, {
status: event.status,
})
.subscribe({
error: (error) => {
const current = this.project();
if (!current) {
return;
}
const updatedTasks = (current.tasks ?? []).map((task) =>
task.id === event.task.id
? {
...task,
status: event.previousStatus,
}
: task,
);
this.project.set({
...current,
tasks: updatedTasks,
});
this.errorMessage.set(error?.error?.message || 'Failed to update task status.');
},
});
}
onAddCollaborator() {
if (this.projectId == null) {
return;
}
this.router.navigate(['/projects', this.projectId, 'collaborators', 'new']);
}
onDeleteProject() {
if (this.projectId == null) {
return;
}
this.apiService.delete<void>(`/projects/${this.projectId}`).subscribe({
next: () => {
this.router.navigate(['/']);
},
error: (error) => {
this.errorMessage.set(error?.error?.message || 'Failed to delete project.');
},
});
}
onEditProject() {
if (this.projectId == null) {
return;
}
this.router.navigate(['/projects', this.projectId, 'edit']);
}
trackByTaskId(_index: number, task: Task): number {
return task.id;
}
trackByUserId(_index: number, user: User): string | number {
return user.id;
}
}

View file

@ -0,0 +1,142 @@
.edit-project {
min-height: 100vh;
background-color: var(--background-color);
padding: 2.5rem;
display: flex;
justify-content: center;
align-items: flex-start;
}
.card {
width: 100%;
max-width: 40rem;
height: fit-content;
background: var(--secondary-color);
border-radius: 0.75rem;
padding: 2rem;
box-shadow: 0 0.125rem 0.5rem var(--shadow-color);
}
.card h1 {
margin: 0 0 1.5rem 0;
font-size: 1.75rem;
color: var(--text-color);
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: var(--text-color);
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 0.75rem 0.875rem;
border: 0.0625rem solid var(--secondary-color);
border-radius: 0.5rem;
font-size: 0.875rem;
box-sizing: border-box;
}
.form-group textarea {
resize: vertical;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 0.75rem;
}
.btn-primary {
padding: 0.625rem 1rem;
background-color: var(--add-color);
color: var(--secondary-color);
border: none;
border-radius: 0.5rem;
font-weight: 600;
cursor: pointer;
}
.btn-primary:hover:not(:disabled) {
background-color: var(--danger-secondary-color-hover);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-secondary {
padding: 0.625rem 1rem;
background: none;
border: 0.0625rem solid var(--secondary-color);
color: var(--text-color);
border-radius: 0.5rem;
font-weight: 600;
cursor: pointer;
}
.error {
margin-bottom: 1rem;
padding: 0.75rem;
border-radius: 0.5rem;
background-color: var(--danger-bg-color);
color: var(--danger-text-color);
}
.loading {
margin: 0;
color: var(--text-color);
}
/* Mobile Responsive */
@media (max-width: 48rem) {
.edit-project {
padding: 1.5rem 1rem;
}
.card {
padding: 1.5rem;
}
.card h1 {
font-size: 1.5rem;
margin-bottom: 1.25rem;
}
.actions {
flex-direction: column-reverse;
}
.btn-primary,
.btn-secondary {
width: 100%;
}
}
@media (max-width: 30rem) {
.edit-project {
padding: 1rem 0.75rem;
}
.card {
padding: 1.25rem;
}
.card h1 {
font-size: 1.375rem;
}
.form-group input,
.form-group textarea {
font-size: 0.8125rem;
}
}

View file

@ -0,0 +1,51 @@
<div class="edit-project">
<div class="card">
<h1>Edit project</h1>
<div class="error" *ngIf="errorMessage()">{{ errorMessage() }}</div>
<ng-container *ngIf="isLoading(); else formContent">
<p class="loading">Loading project...</p>
</ng-container>
<ng-template #formContent>
<form (ngSubmit)="onSubmit()" #projectForm="ngForm">
<div class="form-group">
<label for="name">Project name</label>
<input
id="name"
type="text"
name="name"
[(ngModel)]="name"
placeholder="Enter a project name"
required
/>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea
id="description"
name="description"
[(ngModel)]="description"
rows="4"
placeholder="Enter a project description"
required
></textarea>
</div>
<div class="actions">
<button type="button" class="btn-secondary" (click)="onCancel()">Cancel</button>
<button
type="submit"
class="btn-primary"
[disabled]="isSaving() || !projectForm.form.valid"
>
<span *ngIf="isSaving(); else saveText">Saving...</span>
<ng-template #saveText><span>Save changes</span></ng-template>
</button>
</div>
</form>
</ng-template>
</div>
</div>

View file

@ -0,0 +1,95 @@
import { Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { ApiService } from '../../services/api.service';
import { Project, UpdateProjectRequest } from '../../models/projects.models';
@Component({
selector: 'app-project-edit',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './project-edit.component.html',
styleUrl: './project-edit.component.css',
})
export class ProjectEditComponent {
private apiService = inject(ApiService);
private route = inject(ActivatedRoute);
private router = inject(Router);
name = '';
description = '';
isSaving = signal(false);
isLoading = signal(true);
errorMessage = signal('');
private projectId: number | null = null;
constructor() {
const idParam = this.route.snapshot.paramMap.get('id');
const projectId = idParam ? Number(idParam) : Number.NaN;
if (!Number.isFinite(projectId)) {
this.isLoading.set(false);
this.errorMessage.set('Invalid project id.');
return;
}
this.projectId = projectId;
this.apiService.get<Project>(`/projects/${projectId}`).subscribe({
next: (project) => {
this.name = project.name ?? '';
this.description = project.description ?? '';
this.isLoading.set(false);
},
error: (error) => {
this.isLoading.set(false);
this.errorMessage.set(error?.error?.message || 'Failed to load project.');
},
});
}
onSubmit() {
if (!this.name.trim()) {
this.errorMessage.set('Project name is required.');
return;
}
if (!this.description.trim()) {
this.errorMessage.set('Project description is required.');
return;
}
if (this.projectId == null) {
this.errorMessage.set('Invalid project id.');
return;
}
const payload: UpdateProjectRequest = {
name: this.name.trim(),
description: this.description.trim(),
};
this.isSaving.set(true);
this.errorMessage.set('');
this.apiService.put<Project>(`/projects/${this.projectId}`, payload).subscribe({
next: () => {
this.isSaving.set(false);
this.router.navigate(['/projects', this.projectId]);
},
error: (error) => {
this.isSaving.set(false);
this.errorMessage.set(error?.error?.message || 'Failed to update project.');
},
});
}
onCancel() {
if (this.projectId != null) {
this.router.navigate(['/projects', this.projectId]);
} else {
this.router.navigate(['/']);
}
}
}

View file

@ -0,0 +1,164 @@
.register-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
padding: 1.25rem;
}
.register-card {
background: var(--secondary-color);
border-radius: 0.75rem;
padding: 2.5rem;
box-shadow: 0 0.625rem 2.5rem var(--shadow-color);
width: 100%;
max-width: 25rem;
}
.register-card h1 {
font-size: 1.5rem;
font-weight: 700;
color: var(--primary-color);
margin: 0 0 0.625rem 0;
text-align: center;
}
.register-card h2 {
font-size: 1.75rem;
font-weight: 600;
color: var(--text-color);
margin: 0 0 1.875rem 0;
text-align: center;
}
.error-message {
background-color: var(--danger-bg-color);
color: var(--danger-text-color);
padding: 0.75rem;
border-radius: 0.375rem;
margin-bottom: 1.25rem;
border: 0.0625rem solid var(--danger-border-color);
font-size: 0.875rem;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: var(--text-color);
font-size: 0.875rem;
}
.form-group input {
width: 100%;
padding: 0.75rem 1rem;
border: 0.125rem solid var(--secondary-color);
border-radius: 0.5rem;
font-size: 0.9375rem;
transition: border-color 0.3s;
box-sizing: border-box;
}
.form-group input:focus {
outline: none;
border-color: var(--primary-color);
}
.form-group input::placeholder {
color: var(--text-color);
}
.btn-primary {
width: 100%;
padding: 0.875rem;
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
color: var(--secondary-color);
border: none;
border-radius: 0.5rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition:
transform 0.2s,
box-shadow 0.2s;
margin-top: 0.625rem;
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-0.125rem);
box-shadow: 0 0.3125rem 1.25rem var(--shadow-color);
}
.btn-primary:active:not(:disabled) {
transform: translateY(0);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.login-link {
margin: 1rem 0 0 0;
text-align: center;
font-size: 0.875rem;
color: var(--text-color);
}
/* Mobile Responsive */
@media (max-width: 48rem) {
.register-container {
padding: 1rem;
}
.register-card {
padding: 2rem 1.5rem;
}
.register-card h1 {
font-size: 1.25rem;
}
.register-card h2 {
font-size: 1.5rem;
margin-bottom: 1.5rem;
}
}
@media (max-width: 30rem) {
.register-container {
padding: 0.75rem;
}
.register-card {
padding: 1.5rem 1rem;
}
.register-card h1 {
font-size: 1.125rem;
}
.register-card h2 {
font-size: 1.375rem;
}
.form-group input {
padding: 0.625rem 0.875rem;
font-size: 0.875rem;
}
}
.login-link a {
color: var(--primary-color);
font-weight: 600;
text-decoration: none;
}
.login-link a:hover {
text-decoration: underline;
}

View file

@ -0,0 +1,62 @@
<div class="register-container">
<div class="register-card">
<h1>{{ 'KanbanCloneAngular' }}</h1>
<h2>Create account</h2>
<div class="error-message" *ngIf="errorMessage()">
{{ errorMessage() }}
</div>
<form (ngSubmit)="onSubmit()" #registerForm="ngForm">
<div class="form-group">
<label for="name">Name</label>
<input
id="name"
type="text"
[(ngModel)]="name"
name="name"
placeholder="Enter your name"
required
autocomplete="name"
/>
</div>
<div class="form-group">
<label for="email">e-mail address</label>
<input
id="email"
type="email"
[(ngModel)]="email"
name="email"
placeholder="Enter your e-mail address"
required
autocomplete="username"
/>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
id="password"
type="password"
[(ngModel)]="password"
name="password"
placeholder="Create a password"
required
autocomplete="new-password"
/>
</div>
<button
type="submit"
class="btn-primary"
[disabled]="isLoading() || !registerForm.form.valid"
>
<span *ngIf="isLoading(); else registerText">Creating...</span>
<ng-template #registerText><span>Create account</span></ng-template>
</button>
</form>
<p class="login-link">Already have an account? <a routerLink="/login">Sign in</a></p>
</div>
</div>

View file

@ -0,0 +1,51 @@
import { Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import { ApiService } from '../../services/api.service';
import { RegisterRequest, RegisterResponse } from '../../models/auth.models';
@Component({
selector: 'app-register',
standalone: true,
imports: [CommonModule, FormsModule, RouterLink],
templateUrl: './register.component.html',
styleUrl: './register.component.css',
})
export class RegisterComponent {
private apiService = inject(ApiService);
private router = inject(Router);
name = '';
email = '';
password = '';
isLoading = signal(false);
errorMessage = signal('');
onSubmit() {
if (!this.name || !this.email || !this.password) {
this.errorMessage.set('Please fill in all fields');
return;
}
const payload: RegisterRequest = {
name: this.name.trim(),
email: this.email.trim(),
password: this.password,
};
this.isLoading.set(true);
this.errorMessage.set('');
this.apiService.post<RegisterResponse>('/users/', payload).subscribe({
next: () => {
this.isLoading.set(false);
this.router.navigate(['/login']);
},
error: (error) => {
this.isLoading.set(false);
this.errorMessage.set(error?.error?.message || 'Registration failed.');
},
});
}
}

View file

@ -0,0 +1,136 @@
.create-task {
min-height: 100vh;
background-color: var(--background-color);
padding: 2.5rem;
display: flex;
justify-content: center;
align-items: flex-start;
}
.card {
width: 100%;
max-width: 40rem;
height: fit-content;
background: var(--secondary-color);
border-radius: 0.75rem;
padding: 2rem;
box-shadow: 0 0.125rem 0.5rem var(--shadow-color);
}
.card h1 {
margin: 0 0 1.5rem 0;
font-size: 1.75rem;
color: var(--text-color);
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: var(--text-color);
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 0.75rem 0.875rem;
border: 0.0625rem solid var(--secondary-color);
border-radius: 0.5rem;
font-size: 0.875rem;
box-sizing: border-box;
background-color: var(--secondary-color);
}
.form-group textarea {
resize: vertical;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 0.75rem;
}
.btn-primary {
padding: 0.625rem 1rem;
background-color: var(--add-color);
color: var(--secondary-color);
border: none;
border-radius: 0.5rem;
font-weight: 600;
cursor: pointer;
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-secondary {
padding: 0.625rem 1rem;
background: none;
border: 0.0625rem solid var(--secondary-color);
color: var(--text-color);
border-radius: 0.5rem;
font-weight: 600;
cursor: pointer;
}
.error {
margin-bottom: 1rem;
padding: 0.75rem;
border-radius: 0.5rem;
background-color: var(--danger-bg-color);
color: var(--danger-text-color);
}
/* Mobile Responsive */
@media (max-width: 48rem) {
.create-task {
padding: 1.5rem 1rem;
}
.card {
padding: 1.5rem;
}
.card h1 {
font-size: 1.5rem;
margin-bottom: 1.25rem;
}
.actions {
flex-direction: column-reverse;
}
.btn-primary,
.btn-secondary {
width: 100%;
}
}
@media (max-width: 30rem) {
.create-task {
padding: 1rem 0.75rem;
}
.card {
padding: 1.25rem;
}
.card h1 {
font-size: 1.375rem;
}
.form-group input,
.form-group textarea,
.form-group select {
font-size: 0.8125rem;
}
}

View file

@ -0,0 +1,51 @@
<div class="create-task">
<div class="card">
<h1>Create task</h1>
<div class="error" *ngIf="errorMessage()">{{ errorMessage() }}</div>
<form (ngSubmit)="onSubmit()" #taskForm="ngForm">
<div class="form-group">
<label for="title">Task title</label>
<input
id="title"
type="text"
name="title"
[(ngModel)]="title"
placeholder="Enter a task title"
required
/>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea
id="description"
name="description"
[(ngModel)]="description"
rows="4"
placeholder="Enter a task description"
></textarea>
</div>
<div class="form-group">
<label for="status">Status</label>
<select id="status" name="status" [(ngModel)]="status" required>
<option value="pending">Pending</option>
<option value="in_progress">In progress</option>
<option value="completed">Completed</option>
<option value="stashed">Stashed</option>
<option value="failed">Failed</option>
</select>
</div>
<div class="actions">
<button type="button" class="btn-secondary" (click)="onCancel()">Cancel</button>
<button type="submit" class="btn-primary" [disabled]="isSaving() || !taskForm.form.valid">
<span *ngIf="isSaving(); else createText">Creating...</span>
<ng-template #createText><span>Create task</span></ng-template>
</button>
</div>
</form>
</div>
</div>

View file

@ -0,0 +1,78 @@
import { Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { ApiService } from '../../services/api.service';
import { CreateTaskRequest, Task } from '../../models/tasks.models';
@Component({
selector: 'app-task-create',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './task-create.component.html',
styleUrls: ['./task-create.component.css'],
})
export class TaskCreateComponent {
private apiService = inject(ApiService);
private route = inject(ActivatedRoute);
private router = inject(Router);
title = '';
description = '';
status: Task['status'] = 'pending';
isSaving = signal(false);
errorMessage = signal('');
private projectId: number | null = null;
constructor() {
const idParam = this.route.snapshot.paramMap.get('id');
const projectId = idParam ? Number(idParam) : Number.NaN;
if (!Number.isFinite(projectId)) {
this.errorMessage.set('Invalid project id.');
return;
}
this.projectId = projectId;
}
onSubmit() {
if (!this.title.trim()) {
this.errorMessage.set('Task title is required.');
return;
}
if (this.projectId == null) {
this.errorMessage.set('Invalid project id.');
return;
}
const payload: CreateTaskRequest = {
title: this.title.trim(),
description: this.description.trim() ? this.description.trim() : undefined,
status: this.status,
};
this.isSaving.set(true);
this.errorMessage.set('');
this.apiService.post<Task>(`/projects/${this.projectId}/tasks`, payload).subscribe({
next: () => {
this.isSaving.set(false);
this.router.navigate(['/projects', this.projectId]);
},
error: (error) => {
this.isSaving.set(false);
this.errorMessage.set(error?.error?.message || 'Failed to create task.');
},
});
}
onCancel() {
if (this.projectId != null) {
this.router.navigate(['/projects', this.projectId]);
} else {
this.router.navigate(['/']);
}
}
}

View file

@ -0,0 +1,144 @@
.create-task {
min-height: 100vh;
background-color: var(--background-color);
padding: 2.5rem;
display: flex;
justify-content: center;
align-items: flex-start;
}
.card {
width: 100%;
max-width: 40rem;
height: fit-content;
background: var(--secondary-color);
border-radius: 0.75rem;
padding: 2rem;
box-shadow: 0 0.125rem 0.5rem var(--shadow-color);
}
.card h1 {
margin: 0 0 1.5rem 0;
font-size: 1.75rem;
color: var(--text-color);
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: var(--text-color);
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 0.75rem 0.875rem;
border: 0.0625rem solid var(--secondary-color);
border-radius: 0.5rem;
font-size: 0.875rem;
box-sizing: border-box;
background-color: var(--secondary-color);
}
.form-group textarea {
resize: vertical;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 0.75rem;
}
.btn-primary {
padding: 0.625rem 1rem;
background-color: var(--add-color);
color: var(--secondary-color);
border: none;
border-radius: 0.5rem;
font-weight: 600;
cursor: pointer;
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-secondary {
padding: 0.625rem 1rem;
background: none;
border: 0.0625rem solid var(--secondary-color);
color: var(--text-color);
border-radius: 0.5rem;
font-weight: 600;
cursor: pointer;
}
.error {
margin-bottom: 1rem;
padding: 0.75rem;
border-radius: 0.5rem;
background-color: var(--danger-bg-color);
color: var(--danger-text-color);
}
.loading {
margin-bottom: 1rem;
padding: 0.75rem;
border-radius: 0.5rem;
background-color: var(--secondary-color);
color: var(--text-color);
}
/* Mobile Responsive */
@media (max-width: 48rem) {
.create-task {
padding: 1.5rem 1rem;
}
.card {
padding: 1.5rem;
}
.card h1 {
font-size: 1.5rem;
margin-bottom: 1.25rem;
}
.actions {
flex-direction: column-reverse;
}
.btn-primary,
.btn-secondary {
width: 100%;
}
}
@media (max-width: 30rem) {
.create-task {
padding: 1rem 0.75rem;
}
.card {
padding: 1.25rem;
}
.card h1 {
font-size: 1.375rem;
}
.form-group input,
.form-group textarea,
.form-group select {
font-size: 0.8125rem;
}
}

View file

@ -0,0 +1,57 @@
<div class="create-task">
<div class="card">
<h1>Edit task</h1>
<div class="error" *ngIf="errorMessage()">{{ errorMessage() }}</div>
<ng-container *ngIf="isLoading(); else formContent">
<div class="loading">Loading task...</div>
</ng-container>
<ng-template #formContent>
<form (ngSubmit)="onSubmit()" #taskForm="ngForm">
<div class="form-group">
<label for="title">Task title</label>
<input
id="title"
type="text"
name="title"
[(ngModel)]="title"
placeholder="Enter a task title"
required
/>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea
id="description"
name="description"
[(ngModel)]="description"
rows="4"
placeholder="Enter a task description"
></textarea>
</div>
<div class="form-group">
<label for="status">Status</label>
<select id="status" name="status" [(ngModel)]="status" required>
<option value="pending">Pending</option>
<option value="in_progress">In progress</option>
<option value="completed">Completed</option>
<option value="stashed">Stashed</option>
<option value="failed">Failed</option>
</select>
</div>
<div class="actions">
<button type="button" class="btn-secondary" (click)="onCancel()">Cancel</button>
<button type="submit" class="btn-primary" [disabled]="isSaving() || !taskForm.form.valid">
<span *ngIf="isSaving(); else saveText">Saving...</span>
<ng-template #saveText><span>Save changes</span></ng-template>
</button>
</div>
</form>
</ng-template>
</div>
</div>

View file

@ -0,0 +1,110 @@
import { Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { ApiService } from '../../services/api.service';
import { Task, UpdateTaskRequest } from '../../models/tasks.models';
@Component({
selector: 'app-task-edit',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './task-edit.component.html',
styleUrls: ['./task-edit.component.css'],
})
export class TaskEditComponent {
private apiService = inject(ApiService);
private route = inject(ActivatedRoute);
private router = inject(Router);
title = '';
description = '';
status: Task['status'] = 'pending';
isLoading = signal(true);
isSaving = signal(false);
errorMessage = signal('');
private projectId: number | null = null;
private taskId: number | null = null;
constructor() {
const navState = this.router.getCurrentNavigation()?.extras.state ?? (history.state as any);
const initialTask = navState?.task as Task | undefined;
const projectParam = this.route.snapshot.paramMap.get('id');
const taskParam = this.route.snapshot.paramMap.get('taskId');
const projectId = projectParam ? Number(projectParam) : Number.NaN;
const taskId = taskParam ? Number(taskParam) : Number.NaN;
if (!Number.isFinite(projectId) || !Number.isFinite(taskId)) {
this.isLoading.set(false);
this.errorMessage.set('Invalid project or task id.');
return;
}
this.projectId = projectId;
this.taskId = taskId;
if (initialTask && initialTask.id === taskId) {
this.title = initialTask.title ?? '';
this.description = initialTask.description ?? '';
this.status = initialTask.status ?? 'pending';
this.isLoading.set(false);
return;
}
this.apiService.get<Task>(`/projects/${projectId}/tasks/${taskId}`).subscribe({
next: (task) => {
this.title = task.title ?? '';
this.description = task.description ?? '';
this.status = task.status ?? 'pending';
this.isLoading.set(false);
},
error: (error) => {
this.isLoading.set(false);
this.errorMessage.set(error?.error?.message || 'Failed to load task.');
},
});
}
onSubmit() {
if (!this.title.trim()) {
this.errorMessage.set('Task title is required.');
return;
}
if (this.projectId == null || this.taskId == null) {
this.errorMessage.set('Invalid project or task id.');
return;
}
const payload: UpdateTaskRequest = {
title: this.title.trim(),
description: this.description.trim() ? this.description.trim() : undefined,
status: this.status,
};
this.isSaving.set(true);
this.errorMessage.set('');
this.apiService
.put<Task>(`/projects/${this.projectId}/tasks/${this.taskId}`, payload)
.subscribe({
next: () => {
this.isSaving.set(false);
this.router.navigate(['/projects', this.projectId]);
},
error: (error) => {
this.isSaving.set(false);
this.errorMessage.set(error?.error?.message || 'Failed to update task.');
},
});
}
onCancel() {
if (this.projectId != null) {
this.router.navigate(['/projects', this.projectId]);
} else {
this.router.navigate(['/']);
}
}
}

View file

@ -0,0 +1,44 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../config/environment';
/**
* Base API service that provides common HTTP operations
* Other services can inject this for reusable API patterns
*/
@Injectable({
providedIn: 'root'
})
export class ApiService {
private http = inject(HttpClient);
protected readonly baseUrl = environment.apiBaseUrl;
/**
* HTTP GET request
*/
get<T>(endpoint: string): Observable<T> {
return this.http.get<T>(`${this.baseUrl}${endpoint}`);
}
/**
* HTTP POST request
*/
post<T>(endpoint: string, body: any): Observable<T> {
return this.http.post<T>(`${this.baseUrl}${endpoint}`, body);
}
/**
* HTTP PUT request
*/
put<T>(endpoint: string, body: any): Observable<T> {
return this.http.put<T>(`${this.baseUrl}${endpoint}`, body);
}
/**
* HTTP DELETE request
*/
delete<T>(endpoint: string): Observable<T> {
return this.http.delete<T>(`${this.baseUrl}${endpoint}`);
}
}

View file

@ -0,0 +1,100 @@
import { Injectable, inject, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, tap, catchError, throwError } from 'rxjs';
import { Router } from '@angular/router';
import { LoginRequest, LoginResponse, User, AuthState } from '../models/auth.models';
import { environment } from '../config/environment';
/**
* Authentication service that manages user login, logout, and session state
* Uses signals for reactive state management
*/
@Injectable({
providedIn: 'root',
})
export class AuthService {
private http = inject(HttpClient);
private router = inject(Router);
// Reactive auth state using signals
private authState = signal<AuthState>({
isAuthenticated: false,
user: null,
});
// Public computed signals for components to consume
readonly isAuthenticated = computed(() => this.authState().isAuthenticated);
readonly currentUser = computed(() => this.authState().user);
/**
* Login with credentials
* The JWT will be set as an HTTP-only cookie by the backend
*/
login(credentials: LoginRequest): Observable<LoginResponse> {
return this.http.post<LoginResponse>(`${environment.apiBaseUrl}/auth/login`, credentials).pipe(
tap((response) => {
if (response.success || response.user) {
this.http.get<User>(`${environment.apiBaseUrl}/me`).subscribe({
next: (user) => {
this.authState.set({ isAuthenticated: true, user });
// Redirect to home/dashboard after successful login
this.router.navigate(['/']);
},
error: () => {
this.authState.set({ isAuthenticated: false, user: null });
},
})
}
}),
);
}
/**
* Logout the current user
* Clears the session cookie on the backend
*/
logout(): Observable<any> {
return this.http.get(`${environment.apiBaseUrl}/me/logout`).pipe(
tap(() => {
// Clear auth state
this.authState.set({
isAuthenticated: false,
user: null,
});
// Redirect to login
this.router.navigate(['/login']);
}),
catchError((error) => {
// Even if logout fails on backend, clear local state
this.clearAuthState();
return throwError(() => error);
}),
);
}
/**
* Check current session / get current user
* Call this on app initialization to restore session state
*/
checkSession(): Observable<User> {
return this.http.get<User>(`${environment.apiBaseUrl}/me`).pipe(
tap((user) => {
this.authState.set({
isAuthenticated: true,
user: user,
});
}),
);
}
/**
* Clear auth state (use when session expires or on error)
*/
clearAuthState(): void {
this.authState.set({
isAuthenticated: false,
user: null,
});
this.router.navigate(['/login']);
}
}

13
src/index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>KanbanCloneAngular</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
<app-root></app-root>
</body>
</html>

6
src/main.ts Normal file
View file

@ -0,0 +1,6 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
bootstrapApplication(App, appConfig)
.catch((err) => console.error(err));

51
src/styles.css Normal file
View file

@ -0,0 +1,51 @@
/* You can add global styles to this file, and also import other style files */
:root {
--background-color: whitesmoke;
--primary-color: #4a90e2;
--secondary-color: #f5f5f5;
--text-color: black;
--shadow-color: rgba(0, 0, 0, 0.3);
--avatar-bg-color: #e0f2fe;
--danger-color: #e74c3c;
--danger-color-hover: #c0392b;
--danger-secondary-color-hover: #c0392b88;
--danger-outline-color: #e74c3c;
--danger-bg-color: #fbe9e7;
--danger-bg-color-hover: #f6d0cb;
--danger-text-color: #8e2720;
--danger-border-color: #f2b8b5;
--add-color: #2ecc71;
--add-color-hover: #27ae60;
}
body {
margin: 0;
width: 100%;
height: fit-content;
font-family: Arial, sans-serif;
background-color: var(--background-color);
color: var(--text-color);
}
* {
box-sizing: border-box;
}
/* Mobile Responsive Global Styles */
@media (max-width: 48rem) {
body {
font-size: 0.9375rem;
}
}
@media (max-width: 30rem) {
body {
font-size: 0.875rem;
}
}

15
tsconfig.app.json Normal file
View file

@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"include": [
"src/**/*.ts"
],
"exclude": [
"src/**/*.spec.ts"
]
}

33
tsconfig.json Normal file
View file

@ -0,0 +1,33 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"compileOnSave": false,
"compilerOptions": {
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"experimentalDecorators": true,
"importHelpers": true,
"target": "ES2022",
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

15
tsconfig.spec.json Normal file
View file

@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"vitest/globals"
]
},
"include": [
"src/**/*.d.ts",
"src/**/*.spec.ts"
]
}