diff --git a/package.json b/package.json index 4edeea5..d727bd0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mytaskapplication", - "version": "1.6.0", + "version": "1.7.0", "main": "app.js", "author": { "name": "Maik Roland Damm", diff --git a/src/app/app.component.html b/src/app/app.component.html index d26f0a4..6142220 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1,19 +1,13 @@ - -
- - @if(showNotifications) { - - } - - - - -
- +
+ + +
+ + @if(showNotifications) { + + } +
-
- - -
- + +
diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 16a3b79..c505e13 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,43 +1,52 @@ -import {Component, inject} from '@angular/core'; +import {Component, inject, OnInit} from '@angular/core'; import {HeaderComponent} from './components/header/header.component'; -import {FilterControlsComponent} from './components/filter-controls/filter-controls.component'; -import {TaskAddComponent} from './components/task-add/task-add.component'; -import {TaskListComponent} from './components/task-list/task-list.component'; -import {TodoProgressbarComponent} from './components/todo-progressbar/todo-progressbar.component'; import {FooterComponent} from './components/footer/footer.component'; import {AnnouncementBoxComponent} from './components/announcement-box/announcement-box.component'; import {DarkModeService} from './services/dark-mode.service'; import {SettingsService} from './services/settings.service'; import {NotificationBoxComponent} from './components/notification-box/notification-box.component'; +import {Router, RouterOutlet} from '@angular/router'; +import {Account} from './interfaces/account'; +import {AccountService} from './services/account.service'; @Component({ selector: 'app-root', - imports: [HeaderComponent, FilterControlsComponent, TaskAddComponent, TaskListComponent, TodoProgressbarComponent, FooterComponent, AnnouncementBoxComponent, NotificationBoxComponent], + imports: [HeaderComponent,FooterComponent, AnnouncementBoxComponent, NotificationBoxComponent, RouterOutlet], templateUrl: './app.component.html', styleUrl: './app.component.css' }) -export class AppComponent { +export class AppComponent implements OnInit { title = 'untitled1'; + useraccount: Account | undefined private settingsService = inject(SettingsService) protected settings = this.settingsService.getSettings(); + private initialChoice = this.settings()[0]?.initialChoice; + get isDarkMode(): boolean { return this.darkModeService.isDarkMode(); } - get showProgressBar():boolean { - return this.settings()[0]?.showProgressBar || false - } - get showNotifications():boolean { return this.settings()[0]?.showNotifications || false } - constructor(private darkModeService: DarkModeService) {} + constructor( + private darkModeService: DarkModeService, + private router: Router, + private accountService: AccountService) { + this.accountService.accountChanged.subscribe(account => { + this.useraccount = account; + }); - toggleDarkMode(): void { - this.darkModeService.toggleDarkMode(); } + ngOnInit() { + if(this.initialChoice === undefined) { + void this.router.navigate(['/initial-choice']); + } else + if(this.settings()[0]?.initialChoice === 'offline') + this.useraccount = this.accountService.loadLocalAccount() + } } diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 235192a..347cb2c 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -1,5 +1,14 @@ import { Routes } from '@angular/router'; +import {TasksComponent} from './components/page/tasks/tasks.component'; +import {InitialChoiceComponent} from './components/initial-choice/initial-choice.component'; +import {CreateAccountComponent} from './components/account/create-account/create-account.component'; +import {TaskMigrationComponent} from './components/task-migration/task-migration.component'; export const routes: Routes = [ - { path: '', redirectTo: 'index.html', pathMatch: 'full' }, + { path: '', redirectTo: 'tasks', pathMatch: 'full' }, + { path: 'initial-choice', component: InitialChoiceComponent}, + { path: 'account/create/:action', component: CreateAccountComponent }, + { path: 'tasks', component: TasksComponent }, + { path: 'tasks/migration', component: TaskMigrationComponent }, + { path: '**', redirectTo: 'index.html' } ]; diff --git a/src/app/components/account/create-account/create-account.component.css b/src/app/components/account/create-account/create-account.component.css new file mode 100644 index 0000000..861e0b4 --- /dev/null +++ b/src/app/components/account/create-account/create-account.component.css @@ -0,0 +1,62 @@ +.card { + border-radius: 0.15rem; +} + +form > *, form > * > * > * { + border-radius: 0; +} + +form > button { + background-color: #4c8bf5; + border: 0; + color: #fff; +} + +form > button:hover { + background-color: #9463f7; + color: rgba(255, 255, 255, 0.85); +} + + +/* Dark theme styles for task-add component */ +.dark-theme { + background-color: var(--surface-color); + border-color: var(--border-color); + color: var(--text-color); +} + +/* Style for input fields in dark mode */ +.dark-theme .form-control { + background-color: #1a1a2e; /* Dark background for input */ + color: var(--text-color); /* White text */ + border-color: var(--border-color); +} + +/* Style for placeholder text in dark mode */ +.dark-theme .form-control::placeholder { + color: rgba(255, 255, 255, 0.6); /* Slightly dimmed for placeholder */ +} + +/* Style for focused input in dark mode */ +.dark-theme .form-control:focus { + background-color: #2a2a3e; /* Slightly lighter when focused */ + box-shadow: 0 0 0 0.25rem rgba(187, 134, 252, 0.25); /* Glow that matches primary color */ +} + +/* Dark theme specific styles for file input */ +.dark-theme input[type="file"].form-control { + background-color: #1a1a2e; + color: var(--text-color); +} + +.dark-theme input[type="file"].form-control::file-selector-button { + background-color: #333345; + color: white; +} + +.dark-theme input[type="file"].form-control::file-selector-button:hover { + background-color: #4c4c6d; +} + + + diff --git a/src/app/components/account/create-account/create-account.component.html b/src/app/components/account/create-account/create-account.component.html new file mode 100644 index 0000000..4356f4a --- /dev/null +++ b/src/app/components/account/create-account/create-account.component.html @@ -0,0 +1,73 @@ +
+
+

Local Account Setup

+

+ To create your local account, simply enter a username, choose a password (which will encrypt all your data), and + upload an avatar of your choice. Be sure to remember this password—if you ever export your data and import it on + another device, you’ll need the exact same secret to restore access. +

+
+ +
+
+ Setup your local account +
+
+
+
+
+ + + @if (accountForm.get('username')?.invalid && (accountForm.get('username')?.dirty || accountForm.get('username')?.touched)) { +
+ Username is required +
+ } + +
+
+ + + @if (accountForm.get('password')?.invalid && (accountForm.get('password')?.dirty || accountForm.get('password')?.touched)) { +
+ Password is required +
+ } + +
+
+ + + @if (!selectedFile && accountForm.dirty) { +
+ Avatar is required +
+ } + +
+
+ + + +
+
+
+
diff --git a/src/app/components/account/create-account/create-account.component.spec.ts b/src/app/components/account/create-account/create-account.component.spec.ts new file mode 100644 index 0000000..b473c88 --- /dev/null +++ b/src/app/components/account/create-account/create-account.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { CreateAccountComponent } from './create-account.component'; + +describe('CreateAccountComponent', () => { + let component: CreateAccountComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CreateAccountComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(CreateAccountComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/components/account/create-account/create-account.component.ts b/src/app/components/account/create-account/create-account.component.ts new file mode 100644 index 0000000..83eac54 --- /dev/null +++ b/src/app/components/account/create-account/create-account.component.ts @@ -0,0 +1,118 @@ +import {Component, OnInit} from '@angular/core'; +import {ActivatedRoute} from '@angular/router'; +import {DarkModeService} from '../../../services/dark-mode.service'; +import {NgClass} from '@angular/common'; +import {FormBuilder, FormGroup, FormsModule, ReactiveFormsModule, Validators} from '@angular/forms'; +import {AccountService} from '../../../services/account.service'; +import {Account} from '../../../interfaces/account'; + +@Component({ + selector: 'app-create-account', + imports: [ + NgClass, + FormsModule, + ReactiveFormsModule + ], + templateUrl: './create-account.component.html', + styleUrl: './create-account.component.css' +}) +export class CreateAccountComponent implements OnInit { + action:string = ''; + offline: boolean = false; + accountForm: FormGroup; + selectedFile: File | null = null; + + private localAccount: Account = { + offline: true, + username: '' + }; + + private onlineAccount: Account = { + offline: false, + username: '' + }; + + constructor(private fb: FormBuilder, + private route: ActivatedRoute, + private darkModeService: DarkModeService, + private accountService: AccountService) + { + this.accountForm = this.fb.group({ + username: ['', Validators.required], + password: ['', Validators.required], + }); + + } + + get isDarkMode(): boolean { + return this.darkModeService.isDarkMode(); + } + + + onFileSelected(event: any) { + if (event.target.files.length > 0) { + this.selectedFile = event.target.files[0]; + } + } + + submitForm() { + if (this.accountForm.valid && this.selectedFile) { + const reader = new FileReader(); + reader.onload = (e) => { + const avatar = e.target?.result as string; + this.createAccount( + this.accountForm.get('username')?.value, + this.accountForm.get('password')?.value, + this.accountForm.get('email')?.value.replace('@', '_at_').replace('.', '_dot_').replace(' ', '_'), + avatar + ); + }; + reader.readAsDataURL(this.selectedFile); + } else { + // Mark all fields as touched to trigger validation messages + this.accountForm.markAllAsTouched(); + } + } + + + ngOnInit(): void { + this.route.params.subscribe(params => { + this.action = params['action']; + this.handleAction(); + }) + } + + createAccount(name: string, password: string, email: string, avatar: string) { + if(this.offline) { + this.localAccount.offline = true; + this.localAccount.username = name; + this.localAccount.localStorageKey = password; + this.localAccount.avatar = avatar; + this.accountService.createAccount(this.localAccount); + } else { + this.onlineAccount.offline = false; + this.onlineAccount.username = name; + this.onlineAccount.email = email; + this.onlineAccount.password = password; + this.onlineAccount.avatar = avatar; + this.accountService.createAccount(this.onlineAccount); + } + } + + private handleAction() { + switch (this.action) { + case 'local': + console.log('Account creation mode'); + this.offline = true; + // Initialize form for creation + break; + case 'online': + console.log('Account editing mode'); + // Load account data and initialize form for editing + break; + default: + console.log('Unknown action:', this.action); + // Handle unknown action + } + } +} diff --git a/src/app/components/footer/footer.component.html b/src/app/components/footer/footer.component.html index 571effe..58ae1af 100644 --- a/src/app/components/footer/footer.component.html +++ b/src/app/components/footer/footer.component.html @@ -7,18 +7,19 @@
- - + + + + + + + + logo
diff --git a/src/app/components/footer/footer.component.ts b/src/app/components/footer/footer.component.ts index f128036..446fa98 100644 --- a/src/app/components/footer/footer.component.ts +++ b/src/app/components/footer/footer.component.ts @@ -1,4 +1,4 @@ -import {Component, HostListener, inject, OnInit} from '@angular/core'; +import {Component, HostListener, inject, Input, OnInit} from '@angular/core'; import {VersionService} from '../../services/version.service'; import {AnnouncementService} from '../../services/announcement.service'; import {Announcement} from '../../interfaces/announcement'; @@ -11,6 +11,7 @@ import {NgbTooltip} from '@ng-bootstrap/ng-bootstrap'; import {ModalService} from '../../services/modal.service'; import {TasksService} from '../../services/tasks.service'; import {NotificationService} from '../../services/notification.service'; +import {Account} from '../../interfaces/account'; @Component({ @@ -131,6 +132,49 @@ export class FooterComponent implements OnInit { // Helper method to update settings in the service + @Input() useraccount!: Account|undefined; + + getUserAvatar(): string { + if (!this.useraccount?.avatar) { + return 'assets/default-avatar.svg'; // Default avatar path + } + + // If the avatar is already a data URL, return it directly + if (this.useraccount.avatar.startsWith('data:')) { + return this.useraccount.avatar; + } + + // Otherwise, try to decode it (if it's just a base64 string without the data URL prefix) + try { + return this.decodeBase64(this.useraccount.avatar); + } catch (error) { + console.error('Error getting user avatar:', error); + return 'assets/default-avatar.png'; + } + } + + decodeBase64(base64String?: string): string { + if (!base64String) { + return 'assets/default-avatar.png'; + } + + // If it's already a data URL, return it + if (base64String.startsWith('data:')) { + return base64String; + } + + try { + // Try to convert a plain base64 string to a data URL + // This assumes the base64String is just the encoded data without the data URL prefix + return `data:image/jpeg;base64,${base64String}`; + } catch (error) { + console.error('Error decoding Base64 string:', error); + return 'assets/default-avatar.png'; + } + } + + + private updateSettings(updates: Partial): void { const currentSettings = this.settingsService.getSettings()(); if (currentSettings && currentSettings.length > 0) { @@ -156,6 +200,7 @@ export class FooterComponent implements OnInit { this.tasksService.clearAllTasks(); localStorage.removeItem('AGTASKS_SETTINGS') localStorage.removeItem('AGTASKS_ANNOUNCEMENTS') + localStorage.removeItem('AGTASKS_ACCOUNT') this.notificationService.addNotification( 'Success!', 'All your user data has been deleted.') diff --git a/src/app/components/initial-choice/initial-choice.component.css b/src/app/components/initial-choice/initial-choice.component.css new file mode 100644 index 0000000..f694abf --- /dev/null +++ b/src/app/components/initial-choice/initial-choice.component.css @@ -0,0 +1,33 @@ +.dark-theme { + img { + filter: invert(1); + } + .card-choice { + cursor: pointer; + color: white; + opacity: 0.5; + transition: all 0.2s ease-in-out; + } + .card-choice:hover { + opacity: 1; + transition: all 0.2s ease-in-out; + } +} + +.card-choice { + cursor: pointer; + opacity: 0.5; + transition: all 0.2s ease-in-out; +} +.card-choice:hover { + opacity: 1; + transition: all 0.2s ease-in-out; +} + +.countdown-timer { + font-size: 1.2rem; + font-weight: bold; + text-align: center; + margin-bottom: 1rem; + color: #dc3545; /* Bootstrap danger color */ +} diff --git a/src/app/components/initial-choice/initial-choice.component.html b/src/app/components/initial-choice/initial-choice.component.html new file mode 100644 index 0000000..b7c4ac5 --- /dev/null +++ b/src/app/components/initial-choice/initial-choice.component.html @@ -0,0 +1,49 @@ +
+
+ @if(showCountdown) { +
+ Redirecting in {{countdown}} seconds... +
+ + } +

Initial Choice

+

+ Welcome aboard! As this is your first time using our application, please decide whether you’d prefer Offline Mode—offering full + functionality without an internet connection and storing all your data securely on your device—or Online Mode, which provides seamless + access anytime you’re connected, with your information synchronized safely to the cloud. Choose the option that best fits your + needs, and let’s get started! +

+
+ +
+
+
+
+
+ offline +
+
+
Offline
+

Full functionality without an internet connection and storing all your data securely on your device.

+
+
+
+
+ +
+
+
+
+ offline +
+
+
Online
+

provides seamless access anytime you’re connected, with your information synchronized safely to the cloud.

+
+
+
+
+
+
diff --git a/src/app/components/initial-choice/initial-choice.component.spec.ts b/src/app/components/initial-choice/initial-choice.component.spec.ts new file mode 100644 index 0000000..ebb50d6 --- /dev/null +++ b/src/app/components/initial-choice/initial-choice.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { InitialChoiceComponent } from './initial-choice.component'; + +describe('InitialChoiceComponent', () => { + let component: InitialChoiceComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [InitialChoiceComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(InitialChoiceComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/components/initial-choice/initial-choice.component.ts b/src/app/components/initial-choice/initial-choice.component.ts new file mode 100644 index 0000000..85f4ab1 --- /dev/null +++ b/src/app/components/initial-choice/initial-choice.component.ts @@ -0,0 +1,79 @@ +import {Component, inject} from '@angular/core'; +import {SettingsService} from '../../services/settings.service'; +import {NgClass, NgOptimizedImage} from '@angular/common'; +import {DarkModeService} from '../../services/dark-mode.service'; +import {NotificationService} from '../../services/notification.service'; +import {Router} from '@angular/router'; + +@Component({ + selector: 'app-initial-choice', + imports: [ + NgClass, + NgOptimizedImage + ], + templateUrl: './initial-choice.component.html', + styleUrl: './initial-choice.component.css' +}) +export class InitialChoiceComponent { + + private readonly initialChoice: string|undefined = undefined; + private settingsService: SettingsService = inject(SettingsService); + private notificationService: NotificationService = inject(NotificationService); + + public countdown: number = 5; + public showCountdown: boolean = false; + + constructor(private darkModeService: DarkModeService,private router: Router + ) { + this.initialChoice = this.settingsService.getSettings()()[0]?.initialChoice; + } + + get isDarkMode(): boolean { + return this.darkModeService.isDarkMode(); + } + + isInitialChoice(): boolean { + return this.initialChoice !== undefined; + } + + setInitialChoice(choice: string) { + const currentSettings = this.settingsService.getSettings()(); + + // Create a new settings array with the initialChoice updated + const updatedSettings = currentSettings.map((setting, index) => { + if (index === 0) { + // Update the first settings object with the new initialChoice + return { + ...setting, + initialChoice: choice + }; + } + return setting; + }); + + + console.log("Initial choice set to: " + choice); + if (choice === 'offline') { + // Update the settings with the new array + this.settingsService.updateSettings(updatedSettings); + // Redirect to home + void this.router.navigate(['/account/create/local']); + } else if(choice === 'online') { + console.log("Online mode not implemented yet.") + this.notificationService.addNotification("Info:", "Online mode not implemented yet."); + + // sleep and show countdown 5 sec + // this.showCountdown = true; + // const intervalId = setInterval(() => { + // this.countdown--; + // + // if (this.countdown <= 0) { + // clearInterval(intervalId); + // window.location.href = '/'; + // } + // }, 1000); + } + + } + +} diff --git a/src/app/components/page/tasks/tasks.component.css b/src/app/components/page/tasks/tasks.component.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/components/page/tasks/tasks.component.html b/src/app/components/page/tasks/tasks.component.html new file mode 100644 index 0000000..a5e5bfa --- /dev/null +++ b/src/app/components/page/tasks/tasks.component.html @@ -0,0 +1,10 @@ + + + + +
+ +
+
+ + diff --git a/src/app/components/page/tasks/tasks.component.spec.ts b/src/app/components/page/tasks/tasks.component.spec.ts new file mode 100644 index 0000000..220a8e5 --- /dev/null +++ b/src/app/components/page/tasks/tasks.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { TasksComponent } from './tasks.component'; + +describe('TasksComponent', () => { + let component: TasksComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TasksComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(TasksComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/components/page/tasks/tasks.component.ts b/src/app/components/page/tasks/tasks.component.ts new file mode 100644 index 0000000..bbe2248 --- /dev/null +++ b/src/app/components/page/tasks/tasks.component.ts @@ -0,0 +1,21 @@ +import {Component, inject} from '@angular/core'; +import {FilterControlsComponent} from '../../filter-controls/filter-controls.component'; +import {TaskAddComponent} from '../../task-add/task-add.component'; +import {TodoProgressbarComponent} from '../../todo-progressbar/todo-progressbar.component'; +import {TaskListComponent} from '../../task-list/task-list.component'; +import {SettingsService} from '../../../services/settings.service'; + +@Component({ + selector: 'app-tasks', + imports: [FilterControlsComponent, TaskAddComponent, TodoProgressbarComponent, TaskListComponent], + templateUrl: './tasks.component.html', + styleUrl: './tasks.component.css' +}) +export class TasksComponent { + private settingsService = inject(SettingsService) + protected settings = this.settingsService.getSettings(); + + get showProgressBar():boolean { + return this.settings()[0]?.showProgressBar || false + } +} diff --git a/src/app/components/task-list/task-list.component.html b/src/app/components/task-list/task-list.component.html index a15dba4..8b1c5cc 100644 --- a/src/app/components/task-list/task-list.component.html +++ b/src/app/components/task-list/task-list.component.html @@ -9,7 +9,7 @@
    -
  • + [style.position]="'relative'" [style.z-index]="'1'">{{ task.decryptedDescription }}
    diff --git a/src/app/components/task-list/task-list.component.ts b/src/app/components/task-list/task-list.component.ts index c58ea1a..6fab0bd 100644 --- a/src/app/components/task-list/task-list.component.ts +++ b/src/app/components/task-list/task-list.component.ts @@ -9,6 +9,13 @@ import {NgbTooltip} from '@ng-bootstrap/ng-bootstrap'; import {ClockComponent} from '../clock/clock.component'; import {ModalService} from '../../services/modal.service'; import {NotificationService} from '../../services/notification.service'; +import {AccountService} from '../../services/account.service'; + +// Definiere ein View-Modell nur für die Anzeige +interface TaskView extends Task { + decryptedDescription: string; +} + @Component({ selector: 'app-task-list', @@ -32,10 +39,14 @@ export class TaskListComponent implements OnInit, OnDestroy { private subscription: Subscription | null = null; private notificationService = inject(NotificationService) + // Das ist das View-Modell, das nur für die Anzeige verwendet wird + displayTasks: TaskView[] = []; + constructor(private tasksService: TasksService, private darkModeService: DarkModeService, - private modalService: ModalService) {} + private modalService: ModalService, + private accountService: AccountService) {} get isDarkMode(): boolean { return this.darkModeService.isDarkMode(); @@ -51,32 +62,98 @@ export class TaskListComponent implements OnInit, OnDestroy { }); } + public async decryptTask(string: string): Promise { + return await this.decryptTaskDesk(string); + } /** - * Calculates the number of hours remaining until the due date - * @param dueDate The task's due date - * @returns The number of hours left until the due date, or 0 if the due date is in the past + * Decrypts an encrypted string with the same secret key used for encryption + * @param encryptedText The encrypted string (base64 encoded) + * @returns Promise with the decrypted string */ - calculateRemainingHours(dueDate?: number): number { - if (!dueDate) { - return 0; - } + private async decryptTaskDesk(encryptedText: string): Promise { + const secretKey:string = this.accountService.getSecret() - const now = new Date().getTime(); + if (!encryptedText) return ''; - // If the due date is in the past, return 0 - if (dueDate < now) { - return 0; + try { + // Convert the base64 string back to array + const encryptedData = Uint8Array.from(atob(encryptedText), c => c.charCodeAt(0)); + + // Extract the IV (first 12 bytes) + const iv = encryptedData.slice(0, 12); + + // Extract the encrypted content (everything except first 12 bytes) + const encryptedContent = encryptedData.slice(12); + + // Create a key from the secret + const keyMaterial = await this.getKeyMaterial(secretKey); + const key = await this.deriveKey(keyMaterial); + + // Decrypt the content + const decryptedContent = await crypto.subtle.decrypt( + { + name: 'AES-GCM', + iv + }, + key, + encryptedContent + ); + + // Convert the decrypted content back to string + const textDecoder = new TextDecoder(); + return textDecoder.decode(decryptedContent); + } catch (error) { + console.error('Decryption error:', error); + return ''; } + } - // Calculate the total time span (in milliseconds) - const totalTimeSpan = dueDate - now; - // Convert milliseconds to hours and round to the nearest integer - return Math.round(totalTimeSpan / (1000 * 60 * 60)); + + /** + * Helper function to generate key material from a password + */ + private async getKeyMaterial(password: string): Promise { + const textEncoder = new TextEncoder(); + return await crypto.subtle.importKey( + 'raw', + textEncoder.encode(password), + { name: 'PBKDF2' }, + false, + ['deriveBits', 'deriveKey'] + ); + } + + /** + * Helper function to derive an AES-GCM key from key material + */ + private async deriveKey(keyMaterial: CryptoKey): Promise { + // Use a salt (can be a fixed value as long as it's consistent) + const textEncoder = new TextEncoder(); // Fixed: Define textEncoder here + const salt = textEncoder.encode('this-is-a-fixed-salt'); + + return await crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt, + iterations: 100000, + hash: 'SHA-256' + }, + keyMaterial, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'] + ); } - calculateTimeLeftPercentage(dueDate?: number): number { + + /** + * Calculates the number of hours remaining until the due date + * @param dueDate The task's due date + * @returns The number of hours left until the due date, or 0 if the due date is in the past + */ + calculateRemainingHours(dueDate?: number): number { if (!dueDate) { return 0; } @@ -91,12 +168,10 @@ export class TaskListComponent implements OnInit, OnDestroy { // Calculate the total time span (in milliseconds) const totalTimeSpan = dueDate - now; - // Limit the maximum timespan to 30 days (as in your original code) - const maxTimeSpan = 30 * 24 * 60 * 60 * 1000; - - // Calculate percentage (capped at 100%) - return Math.min(100, Math.round((totalTimeSpan / maxTimeSpan) * 100)); + // Convert milliseconds to hours and round to the nearest integer + return Math.round(totalTimeSpan / (1000 * 60 * 60)); } + /** * Checks all tasks and creates notifications for those due within one hour */ @@ -122,10 +197,26 @@ export class TaskListComponent implements OnInit, OnDestroy { } } + private async updateDisplayTasks() { + // Erstelle eine neue Instanz des Display-Arrays + this.displayTasks = []; + + // Entschlüssle jeden Task und füge ihn dem Display-Array hinzu + for (const task of this.tasks) { + const displayTask = { + ...task, // Alle Eigenschaften kopieren + decryptedDescription: await this.decryptTaskDesk(task.description) + }; + this.displayTasks.push(displayTask); + } + } ngOnInit() { - this.subscription = this.tasksService.tasks$.subscribe(tasks => { + this.subscription = this.tasksService.tasks$.subscribe(async tasks => { this.tasks = tasks; + // Anzeige-Tasks erstellen und entschlüsseln + await this.updateDisplayTasks(); + this.checkTasksDueSoon(); }); } @@ -146,11 +237,20 @@ export class TaskListComponent implements OnInit, OnDestroy { } drop(event: CdkDragDrop): void { + // Aktualisiere zuerst das Anzeige-Array für sofortige UI-Aktualisierung + moveItemInArray(this.displayTasks, event.previousIndex, event.currentIndex); + + // Dann aktualisiere das Original-Array in der gleichen Weise moveItemInArray(this.tasks, event.previousIndex, event.currentIndex); this.tasks.forEach((task, index) => { task.order = index; }); + + this.displayTasks.forEach((task, index) => { + task.order = index; + }); + this.tasksService.updateTasksOrderForFilter(this.tasks); } } diff --git a/src/app/components/task-migration/task-migration.component.css b/src/app/components/task-migration/task-migration.component.css new file mode 100644 index 0000000..e436d86 --- /dev/null +++ b/src/app/components/task-migration/task-migration.component.css @@ -0,0 +1,29 @@ +.dark-theme { + .card { + color: #fff; + .migration-desc { + margin-left: 1rem; + } + + .progress{ + border-radius: 0.15rem; + } + .progress.bg-bar-dark { + background-color: rgba(193, 127, 255, 0.3); + div { + color: #fdfdfd; + } + } + + .bg-info { + background-color: rgba(193, 127, 255, 0.15) !important; + + } + + + + } +} +.progress{ + border-radius: 0.15rem; +} diff --git a/src/app/components/task-migration/task-migration.component.html b/src/app/components/task-migration/task-migration.component.html new file mode 100644 index 0000000..7e2183a --- /dev/null +++ b/src/app/components/task-migration/task-migration.component.html @@ -0,0 +1,39 @@ +
    +
    +

    Task Migration

    +

    + Your tasks from the previous version are now being migrated—please hang tight for a moment. Once the process + completes, you’ll be whisked back to the main view and can continue using the application as usual. +

    +
    +
    +
    +
    + Loading... +
    +
    Migrating your tasks
    +
    +
    +
    +
    + Task: {{currentTaskDescription}} +
    +
    +
    + @if(progress) + { + {{ progress }}% Complete + } +
    +
    +
    +
    +
    + diff --git a/src/app/components/task-migration/task-migration.component.spec.ts b/src/app/components/task-migration/task-migration.component.spec.ts new file mode 100644 index 0000000..95e2fe4 --- /dev/null +++ b/src/app/components/task-migration/task-migration.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { TaskMigrationComponent } from './task-migration.component'; + +describe('TaskMigrationComponent', () => { + let component: TaskMigrationComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TaskMigrationComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(TaskMigrationComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/components/task-migration/task-migration.component.ts b/src/app/components/task-migration/task-migration.component.ts new file mode 100644 index 0000000..bfddc05 --- /dev/null +++ b/src/app/components/task-migration/task-migration.component.ts @@ -0,0 +1,169 @@ +import {Component, OnInit} from '@angular/core'; +import {NgClass} from '@angular/common'; +import {DarkModeService} from '../../services/dark-mode.service'; +import {Task} from '../../interfaces/task'; +import {TasksService} from '../../services/tasks.service'; +import {Router} from '@angular/router'; +import {AccountService} from '../../services/account.service'; + +@Component({ + selector: 'app-task-migration', + imports: [ + NgClass + ], + templateUrl: './task-migration.component.html', + styleUrl: './task-migration.component.css' +}) +export class TaskMigrationComponent implements OnInit{ + + private tasks: Task[] = [] + public tasksCount: number = 0; + public currentTaskID: number = 0; + public currentTaskDescription: string = ''; + + protected secret: string|undefined; + + constructor(private darkModeService: DarkModeService, + private tasksService: TasksService, + private accountService: AccountService, + private router: Router) { + } + + loadSecret(): void { + this.secret = this.accountService.getSecret() + } + + + // Define the sleep function + sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + /** + * Encrypts a string with a given secret key + * @param text The string to encrypt + * @returns Promise with the encrypted string (base64 encoded) + */ + private async encryptTaskDesk(text: string): Promise { + + const secretKey = this.secret || ''; + + if (!text) return ''; + + try { + // Convert strings to proper format for encryption + const textEncoder = new TextEncoder(); + const encodedText = textEncoder.encode(text); + + // Create a key from the secret + const keyMaterial = await this.getKeyMaterial(secretKey); + const key = await this.deriveKey(keyMaterial); + + // Generate a random initialization vector + const iv = crypto.getRandomValues(new Uint8Array(12)); + + // Encrypt the text + const encryptedContent = await crypto.subtle.encrypt( + { + name: 'AES-GCM', + iv + }, + key, + encodedText + ); + + // Combine the IV and encrypted content into a single array + const encryptedContentArray = new Uint8Array(iv.length + encryptedContent.byteLength); + encryptedContentArray.set(iv, 0); + encryptedContentArray.set(new Uint8Array(encryptedContent), iv.length); + + // Convert to Base64 string for storage + return btoa(String.fromCharCode(...encryptedContentArray)); + } catch (error) { + console.error('Encryption error:', error); + return ''; + } + } + + /** + * Helper function to generate key material from a password + */ + private async getKeyMaterial(password: string): Promise { + const textEncoder = new TextEncoder(); + return await crypto.subtle.importKey( + 'raw', + textEncoder.encode(password), + { name: 'PBKDF2' }, + false, + ['deriveBits', 'deriveKey'] + ); + } + + /** + * Helper function to derive an AES-GCM key from key material + */ + private async deriveKey(keyMaterial: CryptoKey): Promise { + // Use a salt (can be a fixed value as long as it's consistent) + const textEncoder = new TextEncoder(); // Fixed: Define textEncoder here + const salt = textEncoder.encode('this-is-a-fixed-salt'); + + return await crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt, + iterations: 100000, + hash: 'SHA-256' + }, + keyMaterial, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'] + ); + } + + + + migrateTasks() { + this.currentTaskID = 0; + this.loadSecret() + const processTasks = async () => { + for(let i = 0; i < this.tasks.length; i++) { + this.currentTaskID = i + 1; + this.currentTaskDescription = this.tasks[i].description || 'Processing task...'; + + // Include function to encrypt tasks with user secret + this.tasks[i].description = await this.encryptTaskDesk(this.currentTaskDescription); + this.tasksService.updateTask(this.tasks[i]); + + // Simulate task processing with a delay + await this.sleep(1000); // Adjust the time as needed for each task + } + + // Final delay after all tasks complete + await this.sleep(2500); + console.log('Migration completed'); + void this.router.navigate(['/']); + // Add your post-migration logic here + }; + + // Start the migration process + processTasks(); + + } + + + get progress(): number { + return Number(((this.currentTaskID / this.tasksCount) * 100).toFixed(0)); + } + + + get isDarkMode(): boolean { + return this.darkModeService.isDarkMode(); + } + + ngOnInit() { + this.tasks = this.tasksService.getTasks(); + this.tasksCount = this.tasks.length; + this.migrateTasks(); // Start migration when component initializes + } +} diff --git a/src/app/interfaces/account.ts b/src/app/interfaces/account.ts new file mode 100644 index 0000000..c9dc37a --- /dev/null +++ b/src/app/interfaces/account.ts @@ -0,0 +1,8 @@ +export interface Account { + offline: boolean; + username: string; + password?: string; + localStorageKey?: string; + email?: string; + avatar?: string; +} diff --git a/src/app/interfaces/settings.ts b/src/app/interfaces/settings.ts index da4d846..6fed453 100644 --- a/src/app/interfaces/settings.ts +++ b/src/app/interfaces/settings.ts @@ -5,4 +5,5 @@ export interface Settings { showTaskCount?: boolean; showNotifications?: boolean; showProgressBar?: boolean; + initialChoice?: string; } diff --git a/src/app/interfaces/task.ts b/src/app/interfaces/task.ts index 4b9a547..d9bd686 100644 --- a/src/app/interfaces/task.ts +++ b/src/app/interfaces/task.ts @@ -7,8 +7,10 @@ export interface TaskOptions { export interface Task { id: number; description: string; + decryptedDescription?: string; completed: boolean; dueDate?: number; options: TaskOptions; + owner?: string; order: number; } diff --git a/src/app/services/account.service.spec.ts b/src/app/services/account.service.spec.ts new file mode 100644 index 0000000..2ffad6f --- /dev/null +++ b/src/app/services/account.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { AccountService } from './account.service'; + +describe('AccountService', () => { + let service: AccountService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(AccountService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/app/services/account.service.ts b/src/app/services/account.service.ts new file mode 100644 index 0000000..4a4b8e0 --- /dev/null +++ b/src/app/services/account.service.ts @@ -0,0 +1,100 @@ +import {EventEmitter, inject, Injectable} from '@angular/core'; +import {Account} from '../interfaces/account'; +import {NotificationService} from './notification.service'; +import {Router} from '@angular/router'; + +@Injectable({ + providedIn: 'root' +}) +export class AccountService { + + private account: Account|undefined; + public accountChanged = new EventEmitter(); + private notificationService = inject(NotificationService); + + constructor(private router: Router) { } + + /** + * Resizes an image to 128x128 pixels and returns it as a data URL + * @param imageDataUrl The original image data URL + * @returns Promise with the resized image data URL + */ + resizeAvatar(imageDataUrl: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => { + // Create a canvas element to resize the image + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + + // Set target dimensions + canvas.width = 128; + canvas.height = 128; + + if (ctx) { + // Draw the image with the new dimensions + ctx.drawImage(img, 0, 0, 128, 128); + + // Get the resized image as a data URL (same type as original) + const format = imageDataUrl.split(';')[0].split(':')[1] || 'image/jpeg'; + const resizedDataUrl = canvas.toDataURL(format, 0.9); // 0.9 quality + resolve(resizedDataUrl); + } else { + reject(new Error('Could not get canvas context')); + } + }; + + img.onerror = () => { + reject(new Error('Failed to load image')); + }; + + // Set the source of the image to the data URL + img.src = imageDataUrl; + }); + } + + + + async createAccount(account: Account) { + // If there's an avatar, resize it + if (account.avatar) { + try { + account.avatar = await this.resizeAvatar(account.avatar); + } catch (error) { + console.error('Failed to resize avatar:', error); + this.notificationService.addNotification('Warning', 'Failed to resize avatar. Using original image.'); + } + } + + this.account = account; + if (!account.offline) { + this.createRemoteAccount(); + } else { + this.createLocalAccount(); + } + + this.accountChanged.emit(this.account); + } + + getSecret(): string { + return this.account?.password || ''; + } + + private createLocalAccount() { + localStorage.setItem('AGTASKS_ACCOUNT', JSON.stringify(this.account)); + this.notificationService.addNotification('Success', 'Account created successfully.'); + void this.router.navigate(['/tasks/migration']); + } + + + private createRemoteAccount() { + console.log('Creating remote account'); + console.log('Not implemented yet.'); + } + + loadLocalAccount(): Account|undefined { + this.account = JSON.parse(localStorage.getItem('AGTASKS_ACCOUNT') || '{}'); + this.accountChanged.emit(this.account); + return this.account; + } +} diff --git a/src/assets/default-avatar.svg b/src/assets/default-avatar.svg new file mode 100644 index 0000000..b172665 --- /dev/null +++ b/src/assets/default-avatar.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/images/offline-svgrepo-com.svg b/src/assets/images/offline-svgrepo-com.svg new file mode 100644 index 0000000..992fee3 --- /dev/null +++ b/src/assets/images/offline-svgrepo-com.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/src/assets/images/online-svgrepo-com.svg b/src/assets/images/online-svgrepo-com.svg new file mode 100644 index 0000000..a8e81d8 --- /dev/null +++ b/src/assets/images/online-svgrepo-com.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/src/styles.css b/src/styles.css index 415391d..d808f7a 100644 --- a/src/styles.css +++ b/src/styles.css @@ -127,3 +127,15 @@ li > .close-btn:hover { left: 100%; } } + + +.page-container { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.content { + flex: 1 0 auto; + padding-bottom: 60px; /* Adjust based on your footer height */ +}