Onboarding
This commit is contained in:
14
Dockerfile
14
Dockerfile
@@ -1,30 +1,26 @@
|
||||
FROM node:22-alpine AS api-deps
|
||||
WORKDIR /app
|
||||
COPY listify-api/package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
FROM node:22-alpine AS api-builder
|
||||
WORKDIR /app
|
||||
COPY listify-api/package*.json ./
|
||||
RUN npm ci
|
||||
RUN npm ci --no-audit --no-fund
|
||||
COPY listify-api/ ./
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine AS web-builder
|
||||
WORKDIR /app
|
||||
COPY listify-client/package*.json ./
|
||||
RUN npm ci
|
||||
RUN npm ci --no-audit --no-fund
|
||||
COPY listify-client/ ./
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine AS runtime
|
||||
RUN apk add --no-cache nginx
|
||||
RUN apk add --no-cache nginx \
|
||||
&& rm -rf /usr/local/lib/node_modules/npm \
|
||||
&& rm -f /usr/local/bin/npm /usr/local/bin/npx
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=api-deps /app/node_modules ./node_modules
|
||||
COPY --from=api-builder /app/dist ./dist
|
||||
|
||||
COPY --from=web-builder /app/dist/listify-client/browser /usr/share/nginx/html
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
"deleteOutDir": true,
|
||||
"webpack": true,
|
||||
"webpackConfigPath": "webpack.config.js"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,18 @@ import {
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import type { AuthenticatedRequest } from './auth.types';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
@@ -36,4 +41,22 @@ export class AuthController {
|
||||
refresh(@Body() refreshTokenDto: RefreshTokenDto) {
|
||||
return this.authService.refresh(refreshTokenDto);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
me(@Req() request: AuthenticatedRequest) {
|
||||
return this.authService.getPublicUser(request.user!.sub);
|
||||
}
|
||||
|
||||
@Patch('me/onboarding')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
updateOnboarding(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Body() body: { completed?: boolean },
|
||||
) {
|
||||
return this.authService.updateOnboardingCompleted(
|
||||
request.user!.sub,
|
||||
body.completed === true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +193,35 @@ export class AuthService {
|
||||
return user.name || user.email;
|
||||
}
|
||||
|
||||
async getPublicUser(userId: string): Promise<PublicUser> {
|
||||
const user = await this.usersRepository.findOne({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Authenticated user is required.');
|
||||
}
|
||||
|
||||
return this.toPublicUser(user);
|
||||
}
|
||||
|
||||
async updateOnboardingCompleted(
|
||||
userId: string,
|
||||
completed: boolean,
|
||||
): Promise<PublicUser> {
|
||||
const user = await this.usersRepository.findOne({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Authenticated user is required.');
|
||||
}
|
||||
|
||||
user.onboardingCompleted = completed;
|
||||
|
||||
return this.toPublicUser(await this.usersRepository.save(user));
|
||||
}
|
||||
|
||||
private normalizeEmail(email?: string): string {
|
||||
const normalizedEmail = email?.trim().toLowerCase();
|
||||
|
||||
@@ -328,6 +357,7 @@ export class AuthService {
|
||||
email: user.email,
|
||||
name: user.name ?? undefined,
|
||||
verified: user.verified,
|
||||
onboardingCompleted: user.onboardingCompleted === true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface AuthUser {
|
||||
passwordHash: string;
|
||||
verificationToken?: string;
|
||||
verified: boolean;
|
||||
onboardingCompleted: boolean;
|
||||
}
|
||||
|
||||
export interface AuthTokens {
|
||||
@@ -34,4 +35,5 @@ export interface PublicUser {
|
||||
email: string;
|
||||
name?: string;
|
||||
verified: boolean;
|
||||
onboardingCompleted: boolean;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,9 @@ export class UserEntity {
|
||||
@Column({ type: 'boolean', default: false })
|
||||
verified!: boolean;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
onboardingCompleted!: boolean;
|
||||
|
||||
@CreateDateColumn({
|
||||
type: 'datetime',
|
||||
precision: 3,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddUserOnboardingCompleted1781000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddUserOnboardingCompleted1781000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
if (!(await queryRunner.hasTable('users'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await queryRunner.hasColumn('users', 'onboardingCompleted'))) {
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE `users` ADD `onboardingCompleted` tinyint NOT NULL DEFAULT 0',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
if (
|
||||
(await queryRunner.hasTable('users')) &&
|
||||
(await queryRunner.hasColumn('users', 'onboardingCompleted'))
|
||||
) {
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE `users` DROP COLUMN `onboardingCompleted`',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class GeneratedMigration1781003163444 implements MigrationInterface {
|
||||
name = 'GeneratedMigration1781003163444'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE \`users\` ADD \`onboardingCompleted\` tinyint NOT NULL DEFAULT 0`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE \`users\` DROP COLUMN \`onboardingCompleted\``);
|
||||
}
|
||||
|
||||
}
|
||||
4
listify-api/webpack.config.js
Normal file
4
listify-api/webpack.config.js
Normal file
@@ -0,0 +1,4 @@
|
||||
module.exports = (options) => ({
|
||||
...options,
|
||||
externals: [],
|
||||
});
|
||||
@@ -10,9 +10,29 @@
|
||||
<mat-icon aria-hidden="true">verified_user</mat-icon>
|
||||
<span>{{ auth.user()?.verified ? 'E-Mail verifiziert' : 'E-Mail nicht verifiziert' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="status-row">
|
||||
<mat-icon aria-hidden="true">school</mat-icon>
|
||||
<span>
|
||||
{{ auth.user()?.onboardingCompleted ? 'Onboarding abgeschlossen' : 'Onboarding offen' }}
|
||||
</span>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
|
||||
<mat-card-actions align="end">
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[disabled]="onboarding.saving()"
|
||||
(click)="resetOnboarding()"
|
||||
>
|
||||
@if (onboarding.saving()) {
|
||||
<mat-progress-spinner mode="indeterminate" diameter="18" />
|
||||
} @else {
|
||||
<mat-icon aria-hidden="true">restart_alt</mat-icon>
|
||||
}
|
||||
Onboarding neu starten
|
||||
</button>
|
||||
<button mat-stroked-button type="button" (click)="logout()">
|
||||
<mat-icon aria-hidden="true">logout</mat-icon>
|
||||
Logout
|
||||
|
||||
@@ -22,6 +22,16 @@
|
||||
color: var(--mat-sys-primary);
|
||||
}
|
||||
|
||||
mat-card-actions {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
mat-card-actions mat-progress-spinner {
|
||||
display: inline-flex;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
.account-page {
|
||||
place-items: center;
|
||||
|
||||
@@ -3,18 +3,25 @@ import { Router } from '@angular/router';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { OnboardingService } from '../onboarding/onboarding.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-account',
|
||||
imports: [MatButtonModule, MatCardModule, MatIconModule],
|
||||
imports: [MatButtonModule, MatCardModule, MatIconModule, MatProgressSpinnerModule],
|
||||
templateUrl: './account.component.html',
|
||||
styleUrl: './account.component.scss',
|
||||
})
|
||||
export class AccountComponent {
|
||||
protected readonly auth = inject(AuthService);
|
||||
protected readonly onboarding = inject(OnboardingService);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
resetOnboarding(): void {
|
||||
this.onboarding.resetForCurrentUser();
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
this.auth.logout();
|
||||
void this.router.navigateByUrl('/login');
|
||||
|
||||
@@ -123,6 +123,8 @@
|
||||
<span>Account</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<app-onboarding-overlay />
|
||||
} @else {
|
||||
<main class="app-main auth-main">
|
||||
<router-outlet />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { BreakpointObserver } from '@angular/cdk/layout';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
||||
@@ -9,6 +9,7 @@ import { MatSidenavModule } from '@angular/material/sidenav';
|
||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||
import { map } from 'rxjs';
|
||||
import { AuthService } from './auth/auth.service';
|
||||
import { OnboardingOverlayComponent } from './onboarding/onboarding-overlay.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
@@ -21,11 +22,12 @@ import { AuthService } from './auth/auth.service';
|
||||
MatListModule,
|
||||
MatSidenavModule,
|
||||
MatToolbarModule,
|
||||
OnboardingOverlayComponent,
|
||||
],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.scss'
|
||||
})
|
||||
export class App {
|
||||
export class App implements OnInit {
|
||||
protected readonly auth = inject(AuthService);
|
||||
private readonly breakpointObserver = inject(BreakpointObserver);
|
||||
|
||||
@@ -35,6 +37,12 @@ export class App {
|
||||
);
|
||||
protected readonly sidebarOpened = signal(false);
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.auth.isAuthenticated()) {
|
||||
this.auth.loadCurrentUser().subscribe({ error: () => undefined });
|
||||
}
|
||||
}
|
||||
|
||||
protected toggleSidebar(): void {
|
||||
this.sidebarOpened.update((opened) => !opened);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ export interface PublicUser {
|
||||
email: string;
|
||||
name?: string;
|
||||
verified: boolean;
|
||||
onboardingCompleted: boolean;
|
||||
}
|
||||
|
||||
export interface AuthTokenResponse {
|
||||
|
||||
@@ -39,6 +39,18 @@ export class AuthService {
|
||||
return this.http.get<VerifyEmailResponse>(`${this.apiUrl}/verify-email`, { params });
|
||||
}
|
||||
|
||||
loadCurrentUser(): Observable<PublicUser> {
|
||||
return this.http
|
||||
.get<PublicUser>(`${this.apiUrl}/me`)
|
||||
.pipe(tap((user) => this.storeUser(user)));
|
||||
}
|
||||
|
||||
updateOnboardingCompleted(completed: boolean): Observable<PublicUser> {
|
||||
return this.http
|
||||
.patch<PublicUser>(`${this.apiUrl}/me/onboarding`, { completed })
|
||||
.pipe(tap((user) => this.storeUser(user)));
|
||||
}
|
||||
|
||||
accessToken(): string | null {
|
||||
return this.storage?.getItem(ACCESS_TOKEN_KEY) ?? null;
|
||||
}
|
||||
@@ -77,8 +89,12 @@ export class AuthService {
|
||||
private storeSession(response: AuthTokenResponse): void {
|
||||
this.storage?.setItem(ACCESS_TOKEN_KEY, response.accessToken);
|
||||
this.storage?.setItem(REFRESH_TOKEN_KEY, response.refreshToken);
|
||||
this.storage?.setItem(USER_KEY, JSON.stringify(response.user));
|
||||
this.userSignal.set(response.user);
|
||||
this.storeUser(response.user);
|
||||
}
|
||||
|
||||
private storeUser(user: PublicUser): void {
|
||||
this.storage?.setItem(USER_KEY, JSON.stringify(user));
|
||||
this.userSignal.set(user);
|
||||
}
|
||||
|
||||
private readStoredUser(): PublicUser | null {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { AuthService } from '../auth.service';
|
||||
import { getAuthErrorMessage } from '../error-message';
|
||||
import { OnboardingService } from '../../onboarding/onboarding.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
@@ -33,6 +34,7 @@ export class LoginComponent {
|
||||
private readonly formBuilder = inject(NonNullableFormBuilder);
|
||||
private readonly router = inject(Router);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly onboarding = inject(OnboardingService);
|
||||
|
||||
protected readonly form = this.formBuilder.group({
|
||||
email: ['', [Validators.required, Validators.email]],
|
||||
@@ -54,7 +56,9 @@ export class LoginComponent {
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open('Login erfolgreich.', 'OK', { duration: 3000 });
|
||||
void this.router.navigateByUrl('/account');
|
||||
if (!this.onboarding.startForCurrentUser()) {
|
||||
void this.router.navigateByUrl('/account');
|
||||
}
|
||||
},
|
||||
error: (error: unknown) => {
|
||||
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
|
||||
|
||||
@@ -12,6 +12,7 @@ import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { getAuthErrorMessage } from '../../auth/error-message';
|
||||
import { OnboardingService } from '../../onboarding/onboarding.service';
|
||||
import { UserList, UserListItem } from '../lists.models';
|
||||
import { ListsService } from '../lists.service';
|
||||
|
||||
@@ -39,6 +40,7 @@ export class ListDetailComponent implements OnInit {
|
||||
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);
|
||||
@@ -68,6 +70,12 @@ export class ListDetailComponent implements OnInit {
|
||||
return;
|
||||
}
|
||||
|
||||
const listId = this.listId();
|
||||
|
||||
if (listId) {
|
||||
this.onboarding.listOpened(listId);
|
||||
}
|
||||
|
||||
this.loadList();
|
||||
}
|
||||
|
||||
|
||||
@@ -31,12 +31,100 @@
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
} @else if (hasLists()) {
|
||||
<section class="list-controls" aria-label="Listen filtern und sortieren">
|
||||
<mat-form-field appearance="outline" class="search-field">
|
||||
<mat-label>Listen suchen</mat-label>
|
||||
<mat-icon matPrefix aria-hidden="true">search</mat-icon>
|
||||
<input
|
||||
matInput
|
||||
type="search"
|
||||
[value]="searchTerm()"
|
||||
(input)="searchTerm.set($any($event.target).value)"
|
||||
/>
|
||||
@if (searchTerm()) {
|
||||
<button
|
||||
mat-icon-button
|
||||
matSuffix
|
||||
type="button"
|
||||
aria-label="Suche loeschen"
|
||||
(click)="searchTerm.set('')"
|
||||
>
|
||||
<mat-icon aria-hidden="true">close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<div class="filter-row">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Typ</mat-label>
|
||||
<mat-select
|
||||
[value]="kindFilter()"
|
||||
(selectionChange)="kindFilter.set($event.value)"
|
||||
>
|
||||
@for (option of kindOptions; track option.value) {
|
||||
<mat-option [value]="option.value">{{ option.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Sortierung</mat-label>
|
||||
<mat-select
|
||||
[value]="sortOption()"
|
||||
(selectionChange)="sortOption.set($event.value)"
|
||||
>
|
||||
@for (option of sortOptions; track option.value) {
|
||||
<mat-option [value]="option.value">{{ option.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="status-row">
|
||||
<mat-button-toggle-group
|
||||
[value]="statusFilter()"
|
||||
(change)="statusFilter.set($event.value)"
|
||||
aria-label="Statusfilter"
|
||||
>
|
||||
<mat-button-toggle value="all">Alle</mat-button-toggle>
|
||||
<mat-button-toggle value="open">Offen</mat-button-toggle>
|
||||
<mat-button-toggle value="completed">Erledigt</mat-button-toggle>
|
||||
</mat-button-toggle-group>
|
||||
|
||||
@if (activeFilterCount() > 0 || sortOption() !== 'updated-desc') {
|
||||
<button mat-button type="button" (click)="resetFilters()">
|
||||
<mat-icon aria-hidden="true">restart_alt</mat-icon>
|
||||
Zuruecksetzen
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (!hasVisibleLists()) {
|
||||
<mat-card class="state-card" appearance="outlined">
|
||||
<mat-card-content>
|
||||
<mat-icon aria-hidden="true">filter_alt_off</mat-icon>
|
||||
<h2>Keine Treffer</h2>
|
||||
<p>Mit den aktuellen Filtern wurde keine Liste gefunden.</p>
|
||||
<button mat-stroked-button type="button" (click)="resetFilters()">
|
||||
<mat-icon aria-hidden="true">restart_alt</mat-icon>
|
||||
Filter zuruecksetzen
|
||||
</button>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
} @else {
|
||||
<p class="result-count">
|
||||
{{ visibleLists().length }} von {{ lists().length }} Listen
|
||||
</p>
|
||||
|
||||
<div class="template-grid">
|
||||
@for (list of lists(); track list.id) {
|
||||
@for (list of visibleLists(); track list.id) {
|
||||
<mat-card class="template-card" appearance="outlined">
|
||||
<mat-card-header>
|
||||
<mat-card-title>{{ list.name }}</mat-card-title>
|
||||
<mat-card-subtitle>{{ kindLabel(list.kind) }}</mat-card-subtitle>
|
||||
<mat-card-subtitle>
|
||||
{{ kindLabel(list.kind) }} - {{ progressLabel(list) }}
|
||||
</mat-card-subtitle>
|
||||
</mat-card-header>
|
||||
|
||||
<mat-card-content>
|
||||
@@ -70,7 +158,11 @@
|
||||
</mat-card-content>
|
||||
|
||||
<mat-card-actions align="end">
|
||||
<a mat-button [routerLink]="['/lists', list.id]">
|
||||
<a
|
||||
mat-button
|
||||
[routerLink]="['/lists', list.id]"
|
||||
[attr.data-onboarding]="onboarding.isListOpenTarget(list.id) ? 'open-list' : null"
|
||||
>
|
||||
<mat-icon aria-hidden="true">open_in_new</mat-icon>
|
||||
Oeffnen
|
||||
</a>
|
||||
@@ -78,6 +170,7 @@
|
||||
</mat-card>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<mat-card class="state-card" appearance="outlined">
|
||||
<mat-card-content>
|
||||
|
||||
66
listify-client/src/app/lists/lists.component.scss
Normal file
66
listify-client/src/app/lists/lists.component.scss
Normal file
@@ -0,0 +1,66 @@
|
||||
.list-controls {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.list-controls mat-form-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-field {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.status-row {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.status-row mat-button-toggle-group {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.status-row mat-button-toggle {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.status-row button {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.result-count {
|
||||
margin: 0 0 0.75rem;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@media (min-width: 701px) {
|
||||
.list-controls {
|
||||
grid-template-columns: minmax(260px, 1.2fr) minmax(320px, 1fr);
|
||||
align-items: start;
|
||||
gap: 0.85rem 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.search-field {
|
||||
grid-row: span 2;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.status-row {
|
||||
grid-template-columns: minmax(300px, 1fr) auto;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
@@ -2,34 +2,115 @@ import { DatePipe } from '@angular/common';
|
||||
import { Component, OnInit, computed, inject, signal } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatButtonToggleModule } from '@angular/material/button-toggle';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
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 { getAuthErrorMessage } from '../auth/error-message';
|
||||
import { OnboardingService } from '../onboarding/onboarding.service';
|
||||
import { ListTemplateKind } from '../templates/templates.models';
|
||||
import { UserList } from './lists.models';
|
||||
import { ListsService } from './lists.service';
|
||||
|
||||
type ListStatusFilter = 'all' | 'open' | 'completed';
|
||||
type ListSortOption =
|
||||
| 'updated-desc'
|
||||
| 'created-desc'
|
||||
| 'name-asc'
|
||||
| 'progress-desc'
|
||||
| 'progress-asc';
|
||||
type ListKindFilter = ListTemplateKind | 'all';
|
||||
|
||||
@Component({
|
||||
selector: 'app-lists',
|
||||
imports: [
|
||||
DatePipe,
|
||||
RouterLink,
|
||||
MatButtonModule,
|
||||
MatButtonToggleModule,
|
||||
MatCardModule,
|
||||
MatFormFieldModule,
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatSelectModule,
|
||||
],
|
||||
templateUrl: './lists.component.html',
|
||||
styleUrl: '../workspace-page.scss',
|
||||
styleUrls: ['../workspace-page.scss', './lists.component.scss'],
|
||||
})
|
||||
export class ListsComponent implements OnInit {
|
||||
private readonly listsService = inject(ListsService);
|
||||
protected readonly onboarding = inject(OnboardingService);
|
||||
|
||||
protected readonly lists = signal<UserList[]>([]);
|
||||
protected readonly loading = signal(true);
|
||||
protected readonly errorMessage = signal<string | null>(null);
|
||||
protected readonly searchTerm = signal('');
|
||||
protected readonly kindFilter = signal<ListKindFilter>('all');
|
||||
protected readonly statusFilter = signal<ListStatusFilter>('all');
|
||||
protected readonly sortOption = signal<ListSortOption>('updated-desc');
|
||||
protected readonly hasLists = computed(() => this.lists().length > 0);
|
||||
protected readonly visibleLists = computed(() => {
|
||||
const term = this.searchTerm().trim().toLowerCase();
|
||||
const kind = this.kindFilter();
|
||||
const status = this.statusFilter();
|
||||
|
||||
return this.lists()
|
||||
.filter((list) => {
|
||||
const matchesSearch =
|
||||
!term ||
|
||||
[
|
||||
list.name,
|
||||
list.description ?? '',
|
||||
this.kindLabel(list.kind),
|
||||
...list.items.flatMap((item) => [item.title, item.notes ?? '']),
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(term);
|
||||
const matchesKind = kind === 'all' || list.kind === kind;
|
||||
const matchesStatus =
|
||||
status === 'all' ||
|
||||
(status === 'completed'
|
||||
? this.isCompleted(list)
|
||||
: !this.isCompleted(list));
|
||||
|
||||
return matchesSearch && matchesKind && matchesStatus;
|
||||
})
|
||||
.sort((a, b) => this.compareLists(a, b));
|
||||
});
|
||||
protected readonly hasVisibleLists = computed(
|
||||
() => this.visibleLists().length > 0,
|
||||
);
|
||||
protected readonly activeFilterCount = computed(
|
||||
() =>
|
||||
Number(this.searchTerm().trim().length > 0) +
|
||||
Number(this.kindFilter() !== 'all') +
|
||||
Number(this.statusFilter() !== 'all'),
|
||||
);
|
||||
protected readonly kindOptions: ReadonlyArray<{
|
||||
value: ListKindFilter;
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: 'all', label: 'Alle Typen' },
|
||||
{ value: 'packing', label: 'Packlisten' },
|
||||
{ value: 'shopping', label: 'Einkauf' },
|
||||
{ value: 'todo', label: 'Todo' },
|
||||
{ value: 'custom', label: 'Eigene Listen' },
|
||||
];
|
||||
protected readonly sortOptions: ReadonlyArray<{
|
||||
value: ListSortOption;
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: 'updated-desc', label: 'Zuletzt bearbeitet' },
|
||||
{ value: 'created-desc', label: 'Neueste zuerst' },
|
||||
{ value: 'name-asc', label: 'Name A-Z' },
|
||||
{ value: 'progress-desc', label: 'Fortschritt hoch' },
|
||||
{ value: 'progress-asc', label: 'Fortschritt niedrig' },
|
||||
];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadLists();
|
||||
@@ -65,4 +146,49 @@ export class ListsComponent implements OnInit {
|
||||
protected checkedCount(list: UserList): number {
|
||||
return list.items.filter((item) => item.checked).length;
|
||||
}
|
||||
|
||||
protected progressLabel(list: UserList): string {
|
||||
if (list.items.length === 0) {
|
||||
return '0%';
|
||||
}
|
||||
|
||||
return `${Math.round(this.progress(list) * 100)}%`;
|
||||
}
|
||||
|
||||
protected resetFilters(): void {
|
||||
this.searchTerm.set('');
|
||||
this.kindFilter.set('all');
|
||||
this.statusFilter.set('all');
|
||||
this.sortOption.set('updated-desc');
|
||||
}
|
||||
|
||||
private compareLists(a: UserList, b: UserList): number {
|
||||
switch (this.sortOption()) {
|
||||
case 'created-desc':
|
||||
return this.dateValue(b.createdAt) - this.dateValue(a.createdAt);
|
||||
case 'name-asc':
|
||||
return a.name.localeCompare(b.name, 'de', { sensitivity: 'base' });
|
||||
case 'progress-desc':
|
||||
return this.progress(b) - this.progress(a);
|
||||
case 'progress-asc':
|
||||
return this.progress(a) - this.progress(b);
|
||||
case 'updated-desc':
|
||||
default:
|
||||
return this.dateValue(b.updatedAt) - this.dateValue(a.updatedAt);
|
||||
}
|
||||
}
|
||||
|
||||
private progress(list: UserList): number {
|
||||
return list.items.length === 0
|
||||
? 0
|
||||
: this.checkedCount(list) / list.items.length;
|
||||
}
|
||||
|
||||
private isCompleted(list: UserList): boolean {
|
||||
return list.items.length > 0 && this.checkedCount(list) === list.items.length;
|
||||
}
|
||||
|
||||
private dateValue(value: string): number {
|
||||
return new Date(value).getTime();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
@if (onboarding.currentStep(); as step) {
|
||||
<div class="onboarding-layer">
|
||||
@if (targetBox(); as box) {
|
||||
<div class="onboarding-marker" [style]="markerStyle()"></div>
|
||||
}
|
||||
|
||||
<section
|
||||
class="onboarding-panel"
|
||||
[class.mobile-panel]="isMobile()"
|
||||
[style]="panelStyle()"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div class="step-line">
|
||||
<span>Schritt {{ onboarding.currentStepNumber() }} von {{ onboarding.totalSteps }}</span>
|
||||
<button mat-icon-button type="button" aria-label="Onboarding schliessen" (click)="onboarding.skip()">
|
||||
<mat-icon aria-hidden="true">close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h2>{{ step.title }}</h2>
|
||||
<p>{{ step.body }}</p>
|
||||
|
||||
<div class="onboarding-actions">
|
||||
@if (!hasTarget() && step.key !== 'complete') {
|
||||
<button mat-stroked-button type="button" (click)="goToCurrentStep()">
|
||||
<mat-icon aria-hidden="true">my_location</mat-icon>
|
||||
Zum Schritt
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (step.key === 'complete') {
|
||||
<button mat-flat-button type="button" (click)="onboarding.finish()">
|
||||
<mat-icon aria-hidden="true">check</mat-icon>
|
||||
Fertig
|
||||
</button>
|
||||
} @else {
|
||||
<button mat-button type="button" (click)="onboarding.skip()">Ueberspringen</button>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
.onboarding-layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.onboarding-marker {
|
||||
position: fixed;
|
||||
border: 2px solid var(--mat-sys-primary);
|
||||
border-radius: 12px;
|
||||
box-shadow:
|
||||
0 0 0 9999px rgb(0 0 0 / 32%),
|
||||
0 0 0 6px color-mix(in srgb, var(--mat-sys-primary) 22%, transparent);
|
||||
pointer-events: none;
|
||||
transition:
|
||||
top 120ms ease,
|
||||
left 120ms ease,
|
||||
width 120ms ease,
|
||||
height 120ms ease;
|
||||
}
|
||||
|
||||
.onboarding-panel {
|
||||
position: fixed;
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
width: min(340px, calc(100vw - 32px));
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--mat-sys-outline-variant);
|
||||
border-radius: 8px;
|
||||
background: var(--mat-sys-surface);
|
||||
box-shadow: var(--mat-sys-level4);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.onboarding-panel.mobile-panel {
|
||||
right: 0.75rem;
|
||||
bottom: calc(0.75rem + env(safe-area-inset-bottom));
|
||||
left: 0.75rem;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.step-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.step-line button {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.onboarding-panel h2 {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.onboarding-panel p {
|
||||
margin: 0;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.onboarding-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import {
|
||||
AfterViewInit,
|
||||
Component,
|
||||
DestroyRef,
|
||||
OnDestroy,
|
||||
computed,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { auditTime, filter, fromEvent, merge } from 'rxjs';
|
||||
import { OnboardingService } from './onboarding.service';
|
||||
|
||||
interface TargetBox {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-onboarding-overlay',
|
||||
imports: [MatButtonModule, MatIconModule],
|
||||
templateUrl: './onboarding-overlay.component.html',
|
||||
styleUrl: './onboarding-overlay.component.scss',
|
||||
})
|
||||
export class OnboardingOverlayComponent implements AfterViewInit, OnDestroy {
|
||||
protected readonly onboarding = inject(OnboardingService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly router = inject(Router);
|
||||
private refreshTimer: number | null = null;
|
||||
private lastStepKey: string | null = null;
|
||||
|
||||
protected readonly targetBox = signal<TargetBox | null>(null);
|
||||
protected readonly viewportWidth = signal(this.readViewportWidth());
|
||||
protected readonly isMobile = computed(() => this.viewportWidth() < 700);
|
||||
protected readonly hasTarget = computed(() => Boolean(this.targetBox()));
|
||||
protected readonly markerStyle = computed(() => {
|
||||
const box = this.targetBox();
|
||||
|
||||
if (!box) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
top: `${Math.max(8, box.top - 8)}px`,
|
||||
left: `${Math.max(8, box.left - 8)}px`,
|
||||
width: `${box.width + 16}px`,
|
||||
height: `${box.height + 16}px`,
|
||||
};
|
||||
});
|
||||
protected readonly panelStyle = computed(() => {
|
||||
const box = this.targetBox();
|
||||
|
||||
if (this.isMobile()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (!box) {
|
||||
return {
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
};
|
||||
}
|
||||
|
||||
const panelWidth = 340;
|
||||
const left =
|
||||
box.left + box.width + panelWidth + 24 < this.viewportWidth()
|
||||
? box.left + box.width + 16
|
||||
: Math.max(16, box.left - panelWidth - 16);
|
||||
|
||||
return {
|
||||
top: `${Math.max(80, box.top)}px`,
|
||||
left: `${left}px`,
|
||||
transform: 'none',
|
||||
};
|
||||
});
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.refreshTimer = window.setInterval(() => this.updateTarget(), 250);
|
||||
|
||||
merge(
|
||||
fromEvent(window, 'resize'),
|
||||
fromEvent(window, 'scroll'),
|
||||
this.router.events.pipe(filter((event) => event instanceof NavigationEnd)),
|
||||
)
|
||||
.pipe(auditTime(80), takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe(() => this.updateTarget());
|
||||
|
||||
window.setTimeout(() => this.updateTarget(true));
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.refreshTimer !== null) {
|
||||
window.clearInterval(this.refreshTimer);
|
||||
}
|
||||
}
|
||||
|
||||
protected goToCurrentStep(): void {
|
||||
this.onboarding.goToCurrentStep();
|
||||
window.setTimeout(() => this.updateTarget(true), 120);
|
||||
}
|
||||
|
||||
private updateTarget(scrollIntoView = false): void {
|
||||
this.viewportWidth.set(this.readViewportWidth());
|
||||
|
||||
const step = this.onboarding.currentStep();
|
||||
const selector = step?.targetSelector;
|
||||
const stepChanged = Boolean(step?.key) && step?.key !== this.lastStepKey;
|
||||
this.lastStepKey = step?.key ?? null;
|
||||
|
||||
if (!selector) {
|
||||
this.targetBox.set(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const target = document.querySelector(selector);
|
||||
|
||||
if (!target) {
|
||||
this.targetBox.set(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (scrollIntoView || stepChanged) {
|
||||
target.scrollIntoView({ block: 'center', inline: 'nearest' });
|
||||
}
|
||||
|
||||
const rect = target.getBoundingClientRect();
|
||||
this.targetBox.set({
|
||||
top: rect.top,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
});
|
||||
}
|
||||
|
||||
private readViewportWidth(): number {
|
||||
return typeof window === 'undefined' ? 1024 : window.innerWidth;
|
||||
}
|
||||
}
|
||||
267
listify-client/src/app/onboarding/onboarding.service.ts
Normal file
267
listify-client/src/app/onboarding/onboarding.service.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { finalize } from 'rxjs';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
|
||||
export type OnboardingStepKey =
|
||||
| 'new-template'
|
||||
| 'template-details'
|
||||
| 'template-item'
|
||||
| 'template-copy'
|
||||
| 'list-open'
|
||||
| 'complete';
|
||||
|
||||
export interface OnboardingStep {
|
||||
key: OnboardingStepKey;
|
||||
title: string;
|
||||
body: string;
|
||||
targetSelector?: string;
|
||||
}
|
||||
|
||||
interface OnboardingState {
|
||||
step: OnboardingStepKey;
|
||||
templateId?: string;
|
||||
listId?: string;
|
||||
}
|
||||
|
||||
const STEPS: Record<OnboardingStepKey, OnboardingStep> = {
|
||||
'new-template': {
|
||||
key: 'new-template',
|
||||
title: 'Erstes Template anlegen',
|
||||
body: 'Starte mit einer Vorlage. Tippe auf "Neues Template".',
|
||||
targetSelector: '[data-onboarding="new-template"]',
|
||||
},
|
||||
'template-details': {
|
||||
key: 'template-details',
|
||||
title: 'Template benennen',
|
||||
body: 'Gib deinem Template einen Titel und speichere es. Danach kannst du Items hinzufuegen.',
|
||||
targetSelector: '[data-onboarding="template-details"]',
|
||||
},
|
||||
'template-item': {
|
||||
key: 'template-item',
|
||||
title: 'Erstes Item hinzufuegen',
|
||||
body: 'Trage ein Item ein und fuege es hinzu. Items sind spaeter die Punkte deiner Liste.',
|
||||
targetSelector: '[data-onboarding="template-item"]',
|
||||
},
|
||||
'template-copy': {
|
||||
key: 'template-copy',
|
||||
title: 'Als Liste kopieren',
|
||||
body: 'Kopiere dein Template als echte Liste, damit du die Items abhaken kannst.',
|
||||
targetSelector: '[data-onboarding="template-copy"]',
|
||||
},
|
||||
'list-open': {
|
||||
key: 'list-open',
|
||||
title: 'Liste oeffnen',
|
||||
body: 'Oeffne die neu erstellte Liste. Dort kannst du die Items abhaken.',
|
||||
targetSelector: '[data-onboarding="open-list"]',
|
||||
},
|
||||
complete: {
|
||||
key: 'complete',
|
||||
title: 'Onboarding abgeschlossen',
|
||||
body: 'Du hast ein Template erstellt, daraus eine Liste gemacht und sie geoeffnet.',
|
||||
},
|
||||
};
|
||||
|
||||
const STEP_ORDER: OnboardingStepKey[] = [
|
||||
'new-template',
|
||||
'template-details',
|
||||
'template-item',
|
||||
'template-copy',
|
||||
'list-open',
|
||||
'complete',
|
||||
];
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class OnboardingService {
|
||||
private readonly auth = inject(AuthService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly stateSignal = signal<OnboardingState | null>(
|
||||
this.readStoredState(),
|
||||
);
|
||||
|
||||
readonly state = this.stateSignal.asReadonly();
|
||||
readonly saving = signal(false);
|
||||
readonly currentStep = computed(() => {
|
||||
if (this.auth.user()?.onboardingCompleted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const state = this.stateSignal();
|
||||
return state ? STEPS[state.step] : null;
|
||||
});
|
||||
readonly currentStepNumber = computed(() => {
|
||||
const step = this.stateSignal()?.step;
|
||||
return step ? STEP_ORDER.indexOf(step) + 1 : 0;
|
||||
});
|
||||
readonly totalSteps = STEP_ORDER.length;
|
||||
|
||||
startForCurrentUser(): boolean {
|
||||
const user = this.auth.user();
|
||||
|
||||
if (!user || user.onboardingCompleted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.setState({ step: 'new-template' });
|
||||
void this.router.navigateByUrl('/templates');
|
||||
return true;
|
||||
}
|
||||
|
||||
skip(): void {
|
||||
this.markCompleted();
|
||||
}
|
||||
|
||||
finish(): void {
|
||||
this.markCompleted();
|
||||
}
|
||||
|
||||
resetForCurrentUser(): void {
|
||||
const userId = this.auth.user()?.id;
|
||||
|
||||
if (!userId || this.saving()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.saving.set(true);
|
||||
this.auth
|
||||
.updateOnboardingCompleted(false)
|
||||
.pipe(finalize(() => this.saving.set(false)))
|
||||
.subscribe(() => {
|
||||
this.storage?.removeItem(this.stateKey(userId));
|
||||
this.setState({ step: 'new-template' });
|
||||
void this.router.navigateByUrl('/templates');
|
||||
});
|
||||
}
|
||||
|
||||
createTemplateClicked(): void {
|
||||
if (this.isStep('new-template')) {
|
||||
this.setState({ step: 'template-details' });
|
||||
}
|
||||
}
|
||||
|
||||
templateCreated(templateId: string): void {
|
||||
if (this.isStep('template-details')) {
|
||||
this.setState({ step: 'template-item', templateId });
|
||||
}
|
||||
}
|
||||
|
||||
templateItemAdded(): void {
|
||||
const state = this.stateSignal();
|
||||
|
||||
if (state?.step === 'template-item') {
|
||||
this.setState({ ...state, step: 'template-copy' });
|
||||
}
|
||||
}
|
||||
|
||||
templateCopiedToList(listId: string): void {
|
||||
const state = this.stateSignal();
|
||||
|
||||
if (state?.step === 'template-copy') {
|
||||
this.setState({ ...state, step: 'list-open', listId });
|
||||
}
|
||||
}
|
||||
|
||||
listOpened(listId: string): void {
|
||||
const state = this.stateSignal();
|
||||
|
||||
if (state?.step === 'list-open' && (!state.listId || state.listId === listId)) {
|
||||
this.setState({ ...state, step: 'complete' });
|
||||
}
|
||||
}
|
||||
|
||||
isStep(step: OnboardingStepKey): boolean {
|
||||
return this.stateSignal()?.step === step;
|
||||
}
|
||||
|
||||
isListOpenTarget(listId: string): boolean {
|
||||
const state = this.stateSignal();
|
||||
return state?.step === 'list-open' && (!state.listId || state.listId === listId);
|
||||
}
|
||||
|
||||
routeForCurrentStep(): string | null {
|
||||
const state = this.stateSignal();
|
||||
|
||||
if (!state) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (state.step) {
|
||||
case 'new-template':
|
||||
return '/templates';
|
||||
case 'template-details':
|
||||
return state.templateId ? `/templates/${state.templateId}` : '/templates/new';
|
||||
case 'template-item':
|
||||
case 'template-copy':
|
||||
return state.templateId ? `/templates/${state.templateId}` : '/templates';
|
||||
case 'list-open':
|
||||
return '/lists';
|
||||
case 'complete':
|
||||
return state.listId ? `/lists/${state.listId}` : null;
|
||||
}
|
||||
}
|
||||
|
||||
goToCurrentStep(): void {
|
||||
const route = this.routeForCurrentStep();
|
||||
|
||||
if (route) {
|
||||
void this.router.navigateByUrl(route);
|
||||
}
|
||||
}
|
||||
|
||||
private setState(state: OnboardingState): void {
|
||||
this.stateSignal.set(state);
|
||||
this.storage?.setItem(this.stateKey(), JSON.stringify(state));
|
||||
}
|
||||
|
||||
private markCompleted(): void {
|
||||
const userId = this.auth.user()?.id;
|
||||
|
||||
if (!userId || this.saving()) {
|
||||
this.stateSignal.set(null);
|
||||
return;
|
||||
}
|
||||
|
||||
this.saving.set(true);
|
||||
this.auth
|
||||
.updateOnboardingCompleted(true)
|
||||
.pipe(finalize(() => this.saving.set(false)))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.storage?.removeItem(this.stateKey(userId));
|
||||
this.stateSignal.set(null);
|
||||
},
|
||||
error: () => {
|
||||
this.stateSignal.set(null);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private readStoredState(): OnboardingState | null {
|
||||
const userId = this.auth.user()?.id;
|
||||
|
||||
if (!userId || this.auth.user()?.onboardingCompleted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawState = this.storage?.getItem(this.stateKey(userId));
|
||||
|
||||
if (!rawState) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(rawState) as OnboardingState;
|
||||
} catch {
|
||||
this.storage?.removeItem(this.stateKey(userId));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private stateKey(userId = this.auth.user()?.id ?? 'anonymous'): string {
|
||||
return `listify.onboarding.state.${userId}`;
|
||||
}
|
||||
|
||||
private get storage(): Storage | null {
|
||||
return typeof window === 'undefined' ? null : window.localStorage;
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,13 @@
|
||||
</div>
|
||||
@if (canEditItems()) {
|
||||
<div class="detail-actions">
|
||||
<button mat-stroked-button type="button" [disabled]="copyingTemplate()" (click)="copyTemplateToList()">
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
data-onboarding="template-copy"
|
||||
[disabled]="copyingTemplate()"
|
||||
(click)="copyTemplateToList()"
|
||||
>
|
||||
@if (copyingTemplate()) {
|
||||
<mat-progress-spinner mode="indeterminate" diameter="18" />
|
||||
} @else {
|
||||
@@ -48,7 +54,7 @@
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
} @else {
|
||||
<mat-card class="editor-card" appearance="outlined">
|
||||
<mat-card class="editor-card" appearance="outlined" data-onboarding="template-details">
|
||||
<mat-card-header>
|
||||
<mat-card-title>Details</mat-card-title>
|
||||
</mat-card-header>
|
||||
@@ -80,7 +86,7 @@
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="editor-card" appearance="outlined">
|
||||
<mat-card class="editor-card" appearance="outlined" data-onboarding="template-item">
|
||||
<mat-card-header>
|
||||
<mat-card-title>Items</mat-card-title>
|
||||
<mat-card-subtitle>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { getAuthErrorMessage } from '../../auth/error-message';
|
||||
import { OnboardingService } from '../../onboarding/onboarding.service';
|
||||
import { ConfirmDeleteDialogComponent } from '../confirm-delete-dialog/confirm-delete-dialog.component';
|
||||
import { ListTemplate } from '../templates.models';
|
||||
import { TemplatesService } from '../templates.service';
|
||||
@@ -43,6 +44,7 @@ export class TemplateDetailComponent implements OnInit {
|
||||
private readonly router = inject(Router);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly templatesService = inject(TemplatesService);
|
||||
protected readonly onboarding = inject(OnboardingService);
|
||||
|
||||
protected readonly template = signal<ListTemplate | null>(null);
|
||||
protected readonly isCreateMode = signal(false);
|
||||
@@ -104,6 +106,7 @@ export class TemplateDetailComponent implements OnInit {
|
||||
|
||||
protected saveTemplate(): void {
|
||||
const templateId = this.templateId();
|
||||
const creatingTemplate = this.isCreateMode() || !templateId;
|
||||
|
||||
if (this.templateForm.invalid) {
|
||||
this.templateForm.markAllAsTouched();
|
||||
@@ -127,11 +130,12 @@ export class TemplateDetailComponent implements OnInit {
|
||||
.subscribe({
|
||||
next: (template) => {
|
||||
this.setTemplate(template);
|
||||
if (this.isCreateMode()) {
|
||||
if (creatingTemplate) {
|
||||
this.isCreateMode.set(false);
|
||||
void this.router.navigate(['/templates', template.id], {
|
||||
replaceUrl: true,
|
||||
});
|
||||
this.onboarding.templateCreated(template.id);
|
||||
}
|
||||
this.snackBar.open('Template gespeichert.', 'OK', { duration: 2500 });
|
||||
},
|
||||
@@ -162,6 +166,7 @@ export class TemplateDetailComponent implements OnInit {
|
||||
next: (template) => {
|
||||
this.setTemplate(template);
|
||||
this.itemForm.reset({ title: '', required: true });
|
||||
this.onboarding.templateItemAdded();
|
||||
},
|
||||
error: (error: unknown) => {
|
||||
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
|
||||
@@ -245,10 +250,11 @@ export class TemplateDetailComponent implements OnInit {
|
||||
.createListFromTemplate(templateId)
|
||||
.pipe(finalize(() => this.copyingTemplate.set(false)))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
next: (list) => {
|
||||
this.snackBar.open('Liste aus Template erstellt.', 'OK', {
|
||||
duration: 3000,
|
||||
});
|
||||
this.onboarding.templateCopiedToList(list.id);
|
||||
void this.router.navigateByUrl('/lists');
|
||||
},
|
||||
error: (error: unknown) => {
|
||||
|
||||
@@ -5,7 +5,12 @@
|
||||
<p>Vorlagen fuer wiederkehrende Listen.</p>
|
||||
</div>
|
||||
|
||||
<a mat-flat-button routerLink="/templates/new">
|
||||
<a
|
||||
mat-flat-button
|
||||
routerLink="/templates/new"
|
||||
data-onboarding="new-template"
|
||||
(click)="onboarding.createTemplateClicked()"
|
||||
>
|
||||
<mat-icon aria-hidden="true">add</mat-icon>
|
||||
Neues Template
|
||||
</a>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { getAuthErrorMessage } from '../auth/error-message';
|
||||
import { OnboardingService } from '../onboarding/onboarding.service';
|
||||
import { ConfirmDeleteDialogComponent } from './confirm-delete-dialog/confirm-delete-dialog.component';
|
||||
import { ListTemplate, ListTemplateKind } from './templates.models';
|
||||
import { TemplatesService } from './templates.service';
|
||||
@@ -33,6 +34,7 @@ export class TemplatesComponent implements OnInit {
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly templatesService = inject(TemplatesService);
|
||||
protected readonly onboarding = inject(OnboardingService);
|
||||
|
||||
protected readonly templates = signal<ListTemplate[]>([]);
|
||||
protected readonly loading = signal(true);
|
||||
|
||||
Reference in New Issue
Block a user