465 lines
14 KiB
TypeScript
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();
|
|
}
|
|
}
|