mirror of
https://github.com/a-mayb3/KanbanCloneAngular.git
synced 2026-03-21 18:05:38 +01:00
Compare commits
No commits in common. "54b4753c7c5315e41ccc4d5c727350fddc064538" and "f3f4ef244ab14dbab4467a104abcae7142c7d2df" have entirely different histories.
54b4753c7c
...
f3f4ef244a
74 changed files with 23 additions and 13366 deletions
|
|
@ -1,17 +0,0 @@
|
||||||
# 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,44 +1,28 @@
|
||||||
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
# Angular specific
|
||||||
|
/dist/
|
||||||
|
/out-tsc/
|
||||||
|
/tmp/
|
||||||
|
/coverage/
|
||||||
|
/e2e/test-output/
|
||||||
|
/.angular/
|
||||||
|
.angular/
|
||||||
|
|
||||||
# Compiled output
|
# Node modules and dependency files
|
||||||
/dist
|
/node_modules/
|
||||||
/tmp
|
/package-lock.json
|
||||||
/out-tsc
|
/yarn.lock
|
||||||
/bazel-out
|
|
||||||
|
|
||||||
# Node
|
# Environment files
|
||||||
/node_modules
|
/.env
|
||||||
npm-debug.log
|
|
||||||
yarn-error.log
|
|
||||||
|
|
||||||
# IDEs and editors
|
# Angular CLI and build artefacts
|
||||||
.idea/
|
/.angular-cli.json
|
||||||
.project
|
/.ng/
|
||||||
.classpath
|
|
||||||
.c9/
|
|
||||||
*.launch
|
|
||||||
.settings/
|
|
||||||
*.sublime-workspace
|
|
||||||
|
|
||||||
# Visual Studio Code
|
# TypeScript cache
|
||||||
.vscode/*
|
*.tsbuildinfo
|
||||||
!.vscode/settings.json
|
|
||||||
!.vscode/tasks.json
|
|
||||||
!.vscode/launch.json
|
|
||||||
!.vscode/extensions.json
|
|
||||||
!.vscode/mcp.json
|
|
||||||
.history/*
|
|
||||||
|
|
||||||
# Miscellaneous
|
# Logs
|
||||||
/.angular/cache
|
npm-debug.log*
|
||||||
.sass-cache/
|
yarn-debug.log*
|
||||||
/connect.lock
|
yarn-error.log*
|
||||||
/coverage
|
|
||||||
/libpeerconnection.log
|
|
||||||
testem.log
|
|
||||||
/typings
|
|
||||||
__screenshots__/
|
|
||||||
|
|
||||||
# System files
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|
|
||||||
4
.vscode/extensions.json
vendored
4
.vscode/extensions.json
vendored
|
|
@ -1,4 +0,0 @@
|
||||||
{
|
|
||||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
|
|
||||||
"recommendations": ["angular.ng-template"]
|
|
||||||
}
|
|
||||||
20
.vscode/launch.json
vendored
20
.vscode/launch.json
vendored
|
|
@ -1,20 +0,0 @@
|
||||||
{
|
|
||||||
// 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
9
.vscode/mcp.json
vendored
|
|
@ -1,9 +0,0 @@
|
||||||
{
|
|
||||||
// For more information, visit: https://angular.dev/ai/mcp
|
|
||||||
"servers": {
|
|
||||||
"angular-cli": {
|
|
||||||
"command": "npx",
|
|
||||||
"args": ["-y", "@angular/cli", "mcp"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
42
.vscode/tasks.json
vendored
42
.vscode/tasks.json
vendored
|
|
@ -1,42 +0,0 @@
|
||||||
{
|
|
||||||
// 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
59
README.md
|
|
@ -1,59 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,279 +0,0 @@
|
||||||
/**
|
|
||||||
* 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
74
angular.json
|
|
@ -1,74 +0,0 @@
|
||||||
{
|
|
||||||
"$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
8636
package-lock.json
generated
File diff suppressed because it is too large
Load diff
43
package.json
43
package.json
|
|
@ -1,43 +0,0 @@
|
||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
|
|
@ -1,44 +0,0 @@
|
||||||
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,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
<app-navbar />
|
|
||||||
<main class="main">
|
|
||||||
<router-outlet />
|
|
||||||
</main>
|
|
||||||
<app-footer />
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
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),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
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 {}
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,161 +0,0 @@
|
||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,169 +0,0 @@
|
||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
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 },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
/**
|
|
||||||
* Environment configuration
|
|
||||||
* Update these values based on your environment (development, production, etc.)
|
|
||||||
*/
|
|
||||||
export const environment = {
|
|
||||||
production: false,
|
|
||||||
apiBaseUrl: 'http://localhost:8000',
|
|
||||||
};
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
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']);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
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);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
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'];
|
|
||||||
}
|
|
||||||
|
|
@ -1,127 +0,0 @@
|
||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
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(['/']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,150 +0,0 @@
|
||||||
.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);
|
|
||||||
}
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,164 +0,0 @@
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
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.',
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
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(['/']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,336 +0,0 @@
|
||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,197 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,142 +0,0 @@
|
||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
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(['/']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,164 +0,0 @@
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
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.');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,136 +0,0 @@
|
||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
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(['/']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,144 +0,0 @@
|
||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
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(['/']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
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']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
<!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>
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
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));
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
/* 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
/* 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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
/* 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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
/* 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