list suggestions
This commit is contained in:
@@ -63,7 +63,9 @@
|
||||
</button>
|
||||
|
||||
@if (canDeleteList()) {
|
||||
<button mat-icon-button
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="delete-list-button"
|
||||
type="button"
|
||||
color="warn"
|
||||
[disabled]="deletingList()"
|
||||
@@ -71,8 +73,10 @@
|
||||
>
|
||||
@if (deletingList()) {
|
||||
<mat-progress-spinner mode="indeterminate" diameter="18" />
|
||||
} @else {
|
||||
<mat-icon aria-hidden="true">delete</mat-icon>
|
||||
}
|
||||
<mat-icon aria-hidden="true">delete</mat-icon>
|
||||
Loeschen
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@@ -95,14 +99,32 @@
|
||||
<textarea matInput formControlName="description" rows="4"></textarea>
|
||||
</mat-form-field>
|
||||
|
||||
<button mat-flat-button type="submit" [disabled]="saving()">
|
||||
@if (saving()) {
|
||||
<mat-progress-spinner mode="indeterminate" diameter="18" />
|
||||
} @else {
|
||||
<mat-icon aria-hidden="true">save</mat-icon>
|
||||
<div class="list-form-actions" [class.create-actions]="isCreateMode()">
|
||||
<button mat-flat-button type="submit" [disabled]="saving() || creatingWithAi()">
|
||||
@if (saving()) {
|
||||
<mat-progress-spinner mode="indeterminate" diameter="18" />
|
||||
} @else {
|
||||
<mat-icon aria-hidden="true">save</mat-icon>
|
||||
}
|
||||
{{ isCreateMode() ? 'Liste anlegen' : 'Speichern' }}
|
||||
</button>
|
||||
|
||||
@if (isCreateMode()) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[disabled]="saving() || creatingWithAi() || !onlineStatus.online()"
|
||||
(click)="createListWithAi()"
|
||||
>
|
||||
@if (creatingWithAi()) {
|
||||
<mat-progress-spinner mode="indeterminate" diameter="18" />
|
||||
} @else {
|
||||
<mat-icon aria-hidden="true">auto_awesome</mat-icon>
|
||||
}
|
||||
Liste mit KI erstellen
|
||||
</button>
|
||||
}
|
||||
{{ isCreateMode() ? 'Liste anlegen' : 'Speichern' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
} @else {
|
||||
<div class="list-summary">
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
width: min(100%, 760px);
|
||||
max-width: 100%;
|
||||
min-height: calc(100dvh - 56px);
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
@@ -14,7 +12,6 @@
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
padding: 0.75rem 0 0.25rem;
|
||||
}
|
||||
|
||||
@@ -37,7 +34,6 @@
|
||||
.sharing-card,
|
||||
.items-card {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
border: 1px solid color-mix(in srgb, var(--mat-sys-outline-variant) 72%, transparent);
|
||||
border-radius: 8px;
|
||||
@@ -51,8 +47,10 @@
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.share-search-field {
|
||||
width: 100%;
|
||||
.detail-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.share-results,
|
||||
@@ -108,10 +106,6 @@
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.editor-card mat-card-header button {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.list-form,
|
||||
.item-form {
|
||||
display: grid;
|
||||
@@ -126,7 +120,12 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.list-form button[type='submit'],
|
||||
.create-actions {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.item-form button[type='submit'] {
|
||||
min-height: 48px;
|
||||
}
|
||||
@@ -236,10 +235,6 @@
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
|
||||
.secondary-back {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
@media (min-width: 701px) {
|
||||
.list-detail-page {
|
||||
gap: 1.25rem;
|
||||
@@ -256,3 +251,9 @@
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.create-actions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ export class ListDetailComponent implements OnInit {
|
||||
protected readonly isCreateMode = signal(false);
|
||||
protected readonly loading = signal(true);
|
||||
protected readonly saving = signal(false);
|
||||
protected readonly creatingWithAi = signal(false);
|
||||
protected readonly creatingTemplate = signal(false);
|
||||
protected readonly deletingList = signal(false);
|
||||
protected readonly editing = signal(false);
|
||||
@@ -163,11 +164,7 @@ export class ListDetailComponent implements OnInit {
|
||||
return;
|
||||
}
|
||||
|
||||
const formValue = this.listForm.getRawValue();
|
||||
const payload = {
|
||||
name: formValue.name.trim(),
|
||||
description: formValue.description.trim() || undefined,
|
||||
};
|
||||
const payload = this.listFormPayload();
|
||||
const saveRequest =
|
||||
this.isCreateMode() || !listId
|
||||
? this.listsService.createList(payload)
|
||||
@@ -193,8 +190,43 @@ export class ListDetailComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
protected createListWithAi(): void {
|
||||
if (!this.isCreateMode() || this.listForm.invalid || this.creatingWithAi()) {
|
||||
this.listForm.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
this.creatingWithAi.set(true);
|
||||
this.suggestionsLoaded.set(false);
|
||||
this.itemSuggestions.set([]);
|
||||
|
||||
this.listsService
|
||||
.createListWithItemSuggestions(this.listFormPayload())
|
||||
.pipe(finalize(() => this.creatingWithAi.set(false)))
|
||||
.subscribe({
|
||||
next: (response) => {
|
||||
this.setList(response.list);
|
||||
this.itemSuggestions.set(response.suggestions);
|
||||
this.suggestionsLoaded.set(true);
|
||||
this.isCreateMode.set(false);
|
||||
this.editing.set(true);
|
||||
this.snackBar.open('Liste mit KI erstellt.', 'OK', {
|
||||
duration: 2500,
|
||||
});
|
||||
void this.router.navigate(['/lists', response.list.id], {
|
||||
replaceUrl: true,
|
||||
});
|
||||
},
|
||||
error: (error: unknown) => {
|
||||
this.snackBar.open(getAuthErrorMessage(error), 'OK', {
|
||||
duration: 5000,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
protected addItem(): void {
|
||||
const listId = this.listId();
|
||||
const listId = this.currentListId();
|
||||
|
||||
if (!listId || !this.canEditItems() || this.itemForm.invalid) {
|
||||
this.itemForm.markAllAsTouched();
|
||||
@@ -286,7 +318,7 @@ export class ListDetailComponent implements OnInit {
|
||||
}
|
||||
|
||||
protected loadSuggestions(): void {
|
||||
const listId = this.listId();
|
||||
const listId = this.currentListId();
|
||||
|
||||
if (!listId || !this.canEditItems() || this.loadingSuggestions()) {
|
||||
return;
|
||||
@@ -310,7 +342,7 @@ export class ListDetailComponent implements OnInit {
|
||||
}
|
||||
|
||||
protected addSuggestion(suggestion: ListItemSuggestion): void {
|
||||
const listId = this.listId();
|
||||
const listId = this.currentListId();
|
||||
|
||||
if (!listId || this.addingSuggestionTitle()) {
|
||||
return;
|
||||
@@ -343,7 +375,7 @@ export class ListDetailComponent implements OnInit {
|
||||
}
|
||||
|
||||
protected toggleItem(item: UserListItem, checked: boolean): void {
|
||||
const listId = this.listId();
|
||||
const listId = this.currentListId();
|
||||
const currentList = this.list();
|
||||
|
||||
if (!listId || !currentList || this.updatingItemId()) {
|
||||
@@ -520,6 +552,19 @@ export class ListDetailComponent implements OnInit {
|
||||
return this.route.snapshot.paramMap.get('listId');
|
||||
}
|
||||
|
||||
private currentListId(): string | null {
|
||||
return this.listId() ?? this.list()?.id ?? null;
|
||||
}
|
||||
|
||||
private listFormPayload() {
|
||||
const formValue = this.listForm.getRawValue();
|
||||
|
||||
return {
|
||||
name: formValue.name.trim(),
|
||||
description: formValue.description.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private uncheckedFirst(items: UserListItem[]): UserListItem[] {
|
||||
return items
|
||||
.map((item, index) => ({ item, index }))
|
||||
|
||||
@@ -167,23 +167,6 @@
|
||||
</mat-card-content>
|
||||
|
||||
<mat-card-actions align="end">
|
||||
@if (list.accessRole === 'owner') {
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
color="warn"
|
||||
[disabled]="deletingListId() === list.id"
|
||||
(click)="deleteList(list)"
|
||||
>
|
||||
@if (deletingListId() === list.id) {
|
||||
<mat-progress-spinner mode="indeterminate" diameter="18" />
|
||||
} @else {
|
||||
<mat-icon aria-hidden="true">delete</mat-icon>
|
||||
}
|
||||
Loeschen
|
||||
</button>
|
||||
}
|
||||
|
||||
<a
|
||||
mat-button
|
||||
[routerLink]="['/lists', list.id]"
|
||||
|
||||
@@ -2,20 +2,16 @@ import { DatePipe } from '@angular/common';
|
||||
import { Component, DestroyRef, OnInit, computed, inject, signal } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { finalize } from 'rxjs';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatButtonToggleModule } from '@angular/material/button-toggle';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
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 { MatSelectModule } from '@angular/material/select';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { getAuthErrorMessage } from '../auth/error-message';
|
||||
import { OnboardingService } from '../onboarding/onboarding.service';
|
||||
import { ConfirmDeleteListDialogComponent } from './confirm-delete-list-dialog/confirm-delete-list-dialog.component';
|
||||
import { ListTemplateKind } from '../templates/templates.models';
|
||||
import { ListRealtimeEvent, UserList, UserListItem } from './lists.models';
|
||||
import { ListsRealtimeService } from './lists-realtime.service';
|
||||
@@ -38,28 +34,23 @@ type ListKindFilter = ListTemplateKind | 'all';
|
||||
MatButtonModule,
|
||||
MatButtonToggleModule,
|
||||
MatCardModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatSelectModule,
|
||||
MatSnackBarModule,
|
||||
],
|
||||
templateUrl: './lists.component.html',
|
||||
styleUrls: ['../workspace-page.scss', './lists.component.scss'],
|
||||
})
|
||||
export class ListsComponent implements OnInit {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly listsService = inject(ListsService);
|
||||
private readonly listsRealtimeService = inject(ListsRealtimeService);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
protected readonly onboarding = inject(OnboardingService);
|
||||
|
||||
protected readonly lists = signal<UserList[]>([]);
|
||||
protected readonly loading = signal(true);
|
||||
protected readonly deletingListId = signal<string | null>(null);
|
||||
protected readonly errorMessage = signal<string | null>(null);
|
||||
protected readonly searchTerm = signal('');
|
||||
protected readonly kindFilter = signal<ListKindFilter>('all');
|
||||
@@ -180,47 +171,6 @@ export class ListsComponent implements OnInit {
|
||||
this.sortOption.set('updated-desc');
|
||||
}
|
||||
|
||||
protected deleteList(list: UserList): void {
|
||||
if (list.accessRole !== 'owner' || this.deletingListId()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dialog
|
||||
.open<ConfirmDeleteListDialogComponent, { listName: string }, boolean>(
|
||||
ConfirmDeleteListDialogComponent,
|
||||
{
|
||||
data: { listName: list.name },
|
||||
maxWidth: '420px',
|
||||
width: 'calc(100vw - 32px)',
|
||||
},
|
||||
)
|
||||
.afterClosed()
|
||||
.subscribe((confirmed) => {
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.deletingListId.set(list.id);
|
||||
|
||||
this.listsService
|
||||
.deleteList(list.id)
|
||||
.pipe(finalize(() => this.deletingListId.set(null)))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.lists.update((lists) =>
|
||||
lists.filter((existingList) => existingList.id !== list.id),
|
||||
);
|
||||
this.snackBar.open('Liste geloescht.', 'OK', { duration: 3000 });
|
||||
},
|
||||
error: (error: unknown) => {
|
||||
this.snackBar.open(getAuthErrorMessage(error), 'OK', {
|
||||
duration: 5000,
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private subscribeToRealtime(): void {
|
||||
this.listsRealtimeService
|
||||
.events()
|
||||
|
||||
@@ -71,6 +71,11 @@ export interface ListItemSuggestionsResponse {
|
||||
suggestions: ListItemSuggestion[];
|
||||
}
|
||||
|
||||
export interface CreateListWithItemSuggestionsResponse {
|
||||
list: UserList;
|
||||
suggestions: ListItemSuggestion[];
|
||||
}
|
||||
|
||||
export interface UpdateListItemRequest {
|
||||
title?: string;
|
||||
notes?: string;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { of, tap, throwError } from 'rxjs';
|
||||
import {
|
||||
AddListItemRequest,
|
||||
CreateListRequest,
|
||||
CreateListWithItemSuggestionsResponse,
|
||||
ListItemSuggestionsResponse,
|
||||
UpdateListItemRequest,
|
||||
UpdateListRequest,
|
||||
@@ -59,6 +60,23 @@ export class ListsService {
|
||||
return this.http.post<UserList>(this.apiUrl, data);
|
||||
}
|
||||
|
||||
createListWithItemSuggestions(
|
||||
data: CreateListRequest,
|
||||
): Observable<CreateListWithItemSuggestionsResponse> {
|
||||
if (!this.onlineStatus.online()) {
|
||||
return throwError(
|
||||
() => new Error('Listen mit KI koennen nur online erstellt werden.'),
|
||||
);
|
||||
}
|
||||
|
||||
return this.http
|
||||
.post<CreateListWithItemSuggestionsResponse>(
|
||||
`${this.apiUrl}/with-item-suggestions`,
|
||||
data,
|
||||
)
|
||||
.pipe(tap((response) => this.upsertCachedList(response.list)));
|
||||
}
|
||||
|
||||
updateList(listId: string, data: UpdateListRequest): Observable<UserList> {
|
||||
if (!this.onlineStatus.online()) {
|
||||
const updatedList = this.updateCachedList(listId, (list) => ({
|
||||
|
||||
Reference in New Issue
Block a user