mirror of
https://github.com/a-mayb3/KanbanCloneAngular.git
synced 2026-03-21 09:55:37 +01:00
commit
54b4753c7c
74 changed files with 13366 additions and 23 deletions
17
.editorconfig
Normal file
17
.editorconfig
Normal 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
62
.gitignore
vendored
|
|
@ -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
4
.vscode/extensions.json
vendored
Normal 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
20
.vscode/launch.json
vendored
Normal 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
9
.vscode/mcp.json
vendored
Normal 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
42
.vscode/tasks.json
vendored
Normal 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
59
README.md
Normal 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
279
USAGE_EXAMPLES.ts
Normal 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
74
angular.json
Normal 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
8636
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
43
package.json
Normal file
43
package.json
Normal 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
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
44
src/app/app.config.ts
Normal file
44
src/app/app.config.ts
Normal 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
0
src/app/app.css
Normal file
5
src/app/app.html
Normal file
5
src/app/app.html
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<app-navbar />
|
||||
<main class="main">
|
||||
<router-outlet />
|
||||
</main>
|
||||
<app-footer />
|
||||
64
src/app/app.routes.ts
Normal file
64
src/app/app.routes.ts
Normal 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
23
src/app/app.spec.ts
Normal 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
18
src/app/app.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
34
src/app/components/footer/footer.component.css
Normal file
34
src/app/components/footer/footer.component.css
Normal 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;
|
||||
}
|
||||
}
|
||||
5
src/app/components/footer/footer.component.html
Normal file
5
src/app/components/footer/footer.component.html
Normal 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>
|
||||
11
src/app/components/footer/footer.component.ts
Normal file
11
src/app/components/footer/footer.component.ts
Normal 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 {}
|
||||
90
src/app/components/navbar/navbar.component.css
Normal file
90
src/app/components/navbar/navbar.component.css
Normal 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;
|
||||
}
|
||||
}
|
||||
9
src/app/components/navbar/navbar.component.html
Normal file
9
src/app/components/navbar/navbar.component.html
Normal 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>
|
||||
28
src/app/components/navbar/navbar.component.ts
Normal file
28
src/app/components/navbar/navbar.component.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
161
src/app/components/project-item/project-item.component.css
Normal file
161
src/app/components/project-item/project-item.component.css
Normal 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;
|
||||
}
|
||||
}
|
||||
14
src/app/components/project-item/project-item.component.html
Normal file
14
src/app/components/project-item/project-item.component.html
Normal 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>
|
||||
43
src/app/components/project-item/project-item.component.ts
Normal file
43
src/app/components/project-item/project-item.component.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
169
src/app/components/task-item/task-item.component.css
Normal file
169
src/app/components/task-item/task-item.component.css
Normal 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;
|
||||
}
|
||||
}
|
||||
23
src/app/components/task-item/task-item.component.html
Normal file
23
src/app/components/task-item/task-item.component.html
Normal 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>
|
||||
81
src/app/components/task-item/task-item.component.ts
Normal file
81
src/app/components/task-item/task-item.component.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
8
src/app/config/environment.ts
Normal file
8
src/app/config/environment.ts
Normal 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',
|
||||
};
|
||||
19
src/app/guards/auth.guard.ts
Normal file
19
src/app/guards/auth.guard.ts
Normal 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']);
|
||||
}
|
||||
};
|
||||
38
src/app/interceptors/http.interceptor.ts
Normal file
38
src/app/interceptors/http.interceptor.ts
Normal 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);
|
||||
}),
|
||||
);
|
||||
};
|
||||
45
src/app/models/auth.models.ts
Normal file
45
src/app/models/auth.models.ts
Normal 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;
|
||||
}
|
||||
36
src/app/models/projects.models.ts
Normal file
36
src/app/models/projects.models.ts
Normal 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;
|
||||
}
|
||||
18
src/app/models/tasks.models.ts
Normal file
18
src/app/models/tasks.models.ts
Normal 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'];
|
||||
}
|
||||
127
src/app/pages/collaborator-add/collaborator-add.component.css
Normal file
127
src/app/pages/collaborator-add/collaborator-add.component.css
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
76
src/app/pages/collaborator-add/collaborator-add.component.ts
Normal file
76
src/app/pages/collaborator-add/collaborator-add.component.ts
Normal 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(['/']);
|
||||
}
|
||||
}
|
||||
}
|
||||
150
src/app/pages/home/home.component.css
Normal file
150
src/app/pages/home/home.component.css
Normal 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);
|
||||
}
|
||||
30
src/app/pages/home/home.component.html
Normal file
30
src/app/pages/home/home.component.html
Normal 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>
|
||||
33
src/app/pages/home/home.component.ts
Normal file
33
src/app/pages/home/home.component.ts
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
164
src/app/pages/login/login.component.css
Normal file
164
src/app/pages/login/login.component.css
Normal 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;
|
||||
}
|
||||
45
src/app/pages/login/login.component.html
Normal file
45
src/app/pages/login/login.component.html
Normal 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>
|
||||
83
src/app/pages/login/login.component.ts
Normal file
83
src/app/pages/login/login.component.ts
Normal 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.',
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
133
src/app/pages/project-create/project-create.component.css
Normal file
133
src/app/pages/project-create/project-create.component.css
Normal 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;
|
||||
}
|
||||
}
|
||||
45
src/app/pages/project-create/project-create.component.html
Normal file
45
src/app/pages/project-create/project-create.component.html
Normal 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>
|
||||
64
src/app/pages/project-create/project-create.component.ts
Normal file
64
src/app/pages/project-create/project-create.component.ts
Normal 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(['/']);
|
||||
}
|
||||
}
|
||||
336
src/app/pages/project-details/project-details.component.css
Normal file
336
src/app/pages/project-details/project-details.component.css
Normal 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;
|
||||
}
|
||||
}
|
||||
112
src/app/pages/project-details/project-details.component.html
Normal file
112
src/app/pages/project-details/project-details.component.html
Normal 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>
|
||||
197
src/app/pages/project-details/project-details.component.ts
Normal file
197
src/app/pages/project-details/project-details.component.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
142
src/app/pages/project-edit/project-edit.component.css
Normal file
142
src/app/pages/project-edit/project-edit.component.css
Normal 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;
|
||||
}
|
||||
}
|
||||
51
src/app/pages/project-edit/project-edit.component.html
Normal file
51
src/app/pages/project-edit/project-edit.component.html
Normal 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>
|
||||
95
src/app/pages/project-edit/project-edit.component.ts
Normal file
95
src/app/pages/project-edit/project-edit.component.ts
Normal 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(['/']);
|
||||
}
|
||||
}
|
||||
}
|
||||
164
src/app/pages/register/register.component.css
Normal file
164
src/app/pages/register/register.component.css
Normal 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;
|
||||
}
|
||||
62
src/app/pages/register/register.component.html
Normal file
62
src/app/pages/register/register.component.html
Normal 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>
|
||||
51
src/app/pages/register/register.component.ts
Normal file
51
src/app/pages/register/register.component.ts
Normal 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.');
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
136
src/app/pages/task-create/task-create.component.css
Normal file
136
src/app/pages/task-create/task-create.component.css
Normal 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;
|
||||
}
|
||||
}
|
||||
51
src/app/pages/task-create/task-create.component.html
Normal file
51
src/app/pages/task-create/task-create.component.html
Normal 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>
|
||||
78
src/app/pages/task-create/task-create.component.ts
Normal file
78
src/app/pages/task-create/task-create.component.ts
Normal 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(['/']);
|
||||
}
|
||||
}
|
||||
}
|
||||
144
src/app/pages/task-edit/task-edit.component.css
Normal file
144
src/app/pages/task-edit/task-edit.component.css
Normal 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;
|
||||
}
|
||||
}
|
||||
57
src/app/pages/task-edit/task-edit.component.html
Normal file
57
src/app/pages/task-edit/task-edit.component.html
Normal 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>
|
||||
110
src/app/pages/task-edit/task-edit.component.ts
Normal file
110
src/app/pages/task-edit/task-edit.component.ts
Normal 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(['/']);
|
||||
}
|
||||
}
|
||||
}
|
||||
44
src/app/services/api.service.ts
Normal file
44
src/app/services/api.service.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
100
src/app/services/auth.service.ts
Normal file
100
src/app/services/auth.service.ts
Normal 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
13
src/index.html
Normal 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
6
src/main.ts
Normal 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
51
src/styles.css
Normal 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
15
tsconfig.app.json
Normal 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
33
tsconfig.json
Normal 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
15
tsconfig.spec.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue