Files
listify/listify-client/src/app/lists/list-detail/list-detail.component.ts
Bastian Wagner cb938d3dc8 Vorschläge
2026-06-15 09:44:59 +02:00

465 lines
14 KiB
TypeScript

import { DatePipe } from '@angular/common';
import { Component, DestroyRef, OnInit, computed, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { finalize } from 'rxjs';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { AuthService } from '../../auth/auth.service';
import { getAuthErrorMessage } from '../../auth/error-message';
import { PublicUserSearchResult } from '../../auth/auth.models';
import { OnboardingService } from '../../onboarding/onboarding.service';
import {
ListItemSuggestion,
ListRealtimeEvent,
UserList,
UserListItem,
} from '../lists.models';
import { ListsRealtimeService } from '../lists-realtime.service';
import { ListsService } from '../lists.service';
@Component({
selector: 'app-list-detail',
imports: [
DatePipe,
ReactiveFormsModule,
RouterLink,
MatButtonModule,
MatCardModule,
MatCheckboxModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatProgressSpinnerModule,
MatSnackBarModule,
],
templateUrl: './list-detail.component.html',
styleUrl: './list-detail.component.scss',
})
export class ListDetailComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef);
private readonly formBuilder = inject(NonNullableFormBuilder);
private readonly authService = inject(AuthService);
private readonly listsService = inject(ListsService);
private readonly listsRealtimeService = inject(ListsRealtimeService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly snackBar = inject(MatSnackBar);
private readonly onboarding = inject(OnboardingService);
protected readonly list = signal<UserList | null>(null);
protected readonly isCreateMode = signal(false);
protected readonly loading = signal(true);
protected readonly saving = signal(false);
protected readonly editing = signal(false);
protected readonly addingItem = signal(false);
protected readonly loadingSuggestions = signal(false);
protected readonly suggestionsLoaded = signal(false);
protected readonly errorMessage = signal<string | null>(null);
protected readonly updatingItemId = signal<string | null>(null);
protected readonly addingSuggestionTitle = signal<string | null>(null);
protected readonly itemSuggestions = signal<ListItemSuggestion[]>([]);
protected readonly shareSearchTerm = signal('');
protected readonly shareSearchResults = signal<PublicUserSearchResult[]>([]);
protected readonly searchingUsers = signal(false);
protected readonly sharingUserId = signal<string | null>(null);
protected readonly removingShareUserId = signal<string | null>(null);
protected readonly canEditItems = computed(() => Boolean(this.list()?.id));
protected readonly canManageShares = computed(
() => this.list()?.accessRole === 'owner' && !this.isCreateMode(),
);
protected readonly showShareControls = computed(
() => this.canManageShares() && this.showEditor(),
);
protected readonly availableShareSearchResults = computed(() => {
const list = this.list();
const collaboratorIds = new Set(
list?.collaborators.map((collaborator) => collaborator.id) ?? [],
);
return this.shareSearchResults().filter(
(user) => user.id !== list?.ownerId && !collaboratorIds.has(user.id),
);
});
protected readonly showEditor = computed(() => this.isCreateMode() || this.editing());
protected readonly listForm = this.formBuilder.group({
name: ['', [Validators.required]],
description: [''],
});
protected readonly itemForm = this.formBuilder.group({
title: ['', [Validators.required]],
required: [true],
});
ngOnInit(): void {
this.isCreateMode.set(this.listId() === null);
if (this.isCreateMode()) {
this.loading.set(false);
this.editing.set(true);
this.listForm.reset({ name: '', description: '' });
return;
}
const listId = this.listId();
if (listId) {
this.onboarding.listOpened(listId);
this.subscribeToRealtime(listId);
}
this.loadList();
}
protected loadList(): void {
const listId = this.listId();
if (!listId) {
this.errorMessage.set('Liste wurde nicht gefunden.');
this.loading.set(false);
return;
}
this.loading.set(true);
this.errorMessage.set(null);
this.listsService.getList(listId).subscribe({
next: (list) => {
this.setList(list);
this.loading.set(false);
},
error: (error: unknown) => {
this.errorMessage.set(getAuthErrorMessage(error));
this.loading.set(false);
},
});
}
protected saveList(): void {
const listId = this.listId();
if (this.listForm.invalid) {
this.listForm.markAllAsTouched();
return;
}
const formValue = this.listForm.getRawValue();
const payload = {
name: formValue.name.trim(),
description: formValue.description.trim() || undefined,
};
const saveRequest =
this.isCreateMode() || !listId
? this.listsService.createList(payload)
: this.listsService.updateList(listId, payload);
this.saving.set(true);
saveRequest.pipe(finalize(() => this.saving.set(false))).subscribe({
next: (list) => {
this.setList(list);
if (this.isCreateMode()) {
this.isCreateMode.set(false);
this.editing.set(false);
void this.router.navigate(['/lists', list.id], { replaceUrl: true });
} else {
this.editing.set(false);
}
this.snackBar.open('Liste gespeichert.', 'OK', { duration: 2500 });
},
error: (error: unknown) => {
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
},
});
}
protected addItem(): void {
const listId = this.listId();
if (!listId || !this.canEditItems() || this.itemForm.invalid) {
this.itemForm.markAllAsTouched();
return;
}
const formValue = this.itemForm.getRawValue();
this.addingItem.set(true);
this.listsService
.addItem(listId, {
title: formValue.title.trim(),
required: formValue.required,
})
.pipe(finalize(() => this.addingItem.set(false)))
.subscribe({
next: (list) => {
this.setList(list);
this.itemForm.reset({ title: '', required: true });
},
error: (error: unknown) => {
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
},
});
}
protected loadSuggestions(): void {
const listId = this.listId();
if (!listId || !this.canEditItems() || this.loadingSuggestions()) {
return;
}
this.loadingSuggestions.set(true);
this.suggestionsLoaded.set(false);
this.listsService
.suggestItems(listId)
.pipe(finalize(() => this.loadingSuggestions.set(false)))
.subscribe({
next: (response) => {
this.itemSuggestions.set(response.suggestions);
this.suggestionsLoaded.set(true);
},
error: (error: unknown) => {
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
},
});
}
protected addSuggestion(suggestion: ListItemSuggestion): void {
const listId = this.listId();
if (!listId || this.addingSuggestionTitle()) {
return;
}
this.addingSuggestionTitle.set(suggestion.title);
this.listsService
.addItem(listId, {
title: suggestion.title,
notes: suggestion.notes,
quantity: suggestion.quantity,
required: suggestion.required,
})
.pipe(finalize(() => this.addingSuggestionTitle.set(null)))
.subscribe({
next: (list) => {
this.setList(list);
this.itemSuggestions.update((suggestions) =>
suggestions.filter(
(itemSuggestion) =>
this.suggestionKey(itemSuggestion.title) !==
this.suggestionKey(suggestion.title),
),
);
},
error: (error: unknown) => {
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
},
});
}
protected toggleItem(item: UserListItem, checked: boolean): void {
const listId = this.listId();
const currentList = this.list();
if (!listId || !currentList || this.updatingItemId()) {
return;
}
this.updatingItemId.set(item.id);
this.list.set({
...currentList,
items: currentList.items.map((existingItem) =>
existingItem.id === item.id ? { ...existingItem, checked } : existingItem,
),
});
this.listsService
.updateItem(listId, item.id, { checked })
.pipe(finalize(() => this.updatingItemId.set(null)))
.subscribe({
next: (list) => {
this.setList(list);
},
error: (error: unknown) => {
this.list.set(currentList);
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
},
});
}
protected startEditing(): void {
this.editing.set(true);
}
protected cancelEditing(): void {
const list = this.list();
if (this.isCreateMode()) {
void this.router.navigateByUrl('/lists');
return;
}
if (list) {
this.listForm.reset({
name: list.name,
description: list.description ?? '',
});
}
this.itemForm.reset({ title: '', required: true });
this.editing.set(false);
}
protected checkedCount(list: UserList): number {
return list.items.filter((item) => item.checked).length;
}
protected visibleItems(list: UserList): UserListItem[] {
return this.uncheckedFirst(list.items);
}
protected searchShareUsers(term: string): void {
this.shareSearchTerm.set(term);
if (term.trim().length < 2) {
this.shareSearchResults.set([]);
return;
}
this.searchingUsers.set(true);
this.authService
.searchUsers(term)
.pipe(finalize(() => this.searchingUsers.set(false)))
.subscribe({
next: (users) => this.shareSearchResults.set(users),
error: (error: unknown) => {
this.shareSearchResults.set([]);
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
},
});
}
protected shareWithUser(user: PublicUserSearchResult): void {
const listId = this.listId();
if (!listId || this.sharingUserId()) {
return;
}
this.sharingUserId.set(user.id);
this.listsService
.shareList(listId, user.id)
.pipe(finalize(() => this.sharingUserId.set(null)))
.subscribe({
next: (list) => {
this.setList(list, !this.showEditor());
this.shareSearchTerm.set('');
this.shareSearchResults.set([]);
this.snackBar.open('Liste geteilt.', 'OK', { duration: 2500 });
},
error: (error: unknown) => {
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
},
});
}
protected removeCollaborator(userId: string): void {
const listId = this.listId();
if (!listId || this.removingShareUserId()) {
return;
}
this.removingShareUserId.set(userId);
this.listsService
.removeShare(listId, userId)
.pipe(finalize(() => this.removingShareUserId.set(null)))
.subscribe({
next: (list) => {
this.setList(list, !this.showEditor());
this.snackBar.open('Freigabe entfernt.', 'OK', { duration: 2500 });
},
error: (error: unknown) => {
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
},
});
}
protected displayUser(user: { name?: string; email: string }): string {
return user.name ? `${user.name} (${user.email})` : user.email;
}
protected async backToLists(): Promise<void> {
await this.router.navigateByUrl('/lists');
}
private subscribeToRealtime(listId: string): void {
this.listsRealtimeService
.events()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (event) => this.applyRealtimeEvent(listId, event),
});
}
private applyRealtimeEvent(listId: string, event: ListRealtimeEvent): void {
if (event.type === 'list.snapshot' && event.data.id === listId) {
this.errorMessage.set(null);
this.loading.set(false);
// Remote snapshots should update visible item state immediately, but they
// must not overwrite a title/description form while the user is editing it.
this.setList(event.data, !this.showEditor());
return;
}
if (event.type === 'list.deleted' && event.data.listId === listId) {
this.list.set(null);
this.loading.set(false);
this.editing.set(false);
this.errorMessage.set('Diese Liste wurde geloescht.');
}
}
private setList(list: UserList, resetForm = true): void {
this.list.set(list);
if (resetForm) {
this.listForm.reset({
name: list.name,
description: list.description ?? '',
});
}
}
private listId(): string | null {
return this.route.snapshot.paramMap.get('listId');
}
private uncheckedFirst(items: UserListItem[]): UserListItem[] {
return items
.map((item, index) => ({ item, index }))
.sort((a, b) => {
if (a.item.checked !== b.item.checked) {
return a.item.checked ? 1 : -1;
}
return a.index - b.index;
})
.map(({ item }) => item);
}
private suggestionKey(value: string): string {
return value.trim().replace(/\s+/g, ' ').toLowerCase();
}
}