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
|
FROM node:22-alpine AS api-builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY listify-api/package*.json ./
|
COPY listify-api/package*.json ./
|
||||||
RUN npm ci
|
RUN npm ci --no-audit --no-fund
|
||||||
COPY listify-api/ ./
|
COPY listify-api/ ./
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM node:22-alpine AS web-builder
|
FROM node:22-alpine AS web-builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY listify-client/package*.json ./
|
COPY listify-client/package*.json ./
|
||||||
RUN npm ci
|
RUN npm ci --no-audit --no-fund
|
||||||
COPY listify-client/ ./
|
COPY listify-client/ ./
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM node:22-alpine AS runtime
|
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 NODE_ENV=production
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=api-deps /app/node_modules ./node_modules
|
|
||||||
COPY --from=api-builder /app/dist ./dist
|
COPY --from=api-builder /app/dist ./dist
|
||||||
|
|
||||||
COPY --from=web-builder /app/dist/listify-client/browser /usr/share/nginx/html
|
COPY --from=web-builder /app/dist/listify-client/browser /usr/share/nginx/html
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
"collection": "@nestjs/schematics",
|
"collection": "@nestjs/schematics",
|
||||||
"sourceRoot": "src",
|
"sourceRoot": "src",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"deleteOutDir": true
|
"deleteOutDir": true,
|
||||||
|
"webpack": true,
|
||||||
|
"webpackConfigPath": "webpack.config.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,18 @@ import {
|
|||||||
Get,
|
Get,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
|
Req,
|
||||||
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import type { AuthenticatedRequest } from './auth.types';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { LoginDto } from './dto/login.dto';
|
import { LoginDto } from './dto/login.dto';
|
||||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||||
import { RegisterDto } from './dto/register.dto';
|
import { RegisterDto } from './dto/register.dto';
|
||||||
|
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||||
|
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
@@ -36,4 +41,22 @@ export class AuthController {
|
|||||||
refresh(@Body() refreshTokenDto: RefreshTokenDto) {
|
refresh(@Body() refreshTokenDto: RefreshTokenDto) {
|
||||||
return this.authService.refresh(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;
|
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 {
|
private normalizeEmail(email?: string): string {
|
||||||
const normalizedEmail = email?.trim().toLowerCase();
|
const normalizedEmail = email?.trim().toLowerCase();
|
||||||
|
|
||||||
@@ -328,6 +357,7 @@ export class AuthService {
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
name: user.name ?? undefined,
|
name: user.name ?? undefined,
|
||||||
verified: user.verified,
|
verified: user.verified,
|
||||||
|
onboardingCompleted: user.onboardingCompleted === true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface AuthUser {
|
|||||||
passwordHash: string;
|
passwordHash: string;
|
||||||
verificationToken?: string;
|
verificationToken?: string;
|
||||||
verified: boolean;
|
verified: boolean;
|
||||||
|
onboardingCompleted: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthTokens {
|
export interface AuthTokens {
|
||||||
@@ -34,4 +35,5 @@ export interface PublicUser {
|
|||||||
email: string;
|
email: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
verified: boolean;
|
verified: boolean;
|
||||||
|
onboardingCompleted: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ export class UserEntity {
|
|||||||
@Column({ type: 'boolean', default: false })
|
@Column({ type: 'boolean', default: false })
|
||||||
verified!: boolean;
|
verified!: boolean;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: false })
|
||||||
|
onboardingCompleted!: boolean;
|
||||||
|
|
||||||
@CreateDateColumn({
|
@CreateDateColumn({
|
||||||
type: 'datetime',
|
type: 'datetime',
|
||||||
precision: 3,
|
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>
|
<mat-icon aria-hidden="true">verified_user</mat-icon>
|
||||||
<span>{{ auth.user()?.verified ? 'E-Mail verifiziert' : 'E-Mail nicht verifiziert' }}</span>
|
<span>{{ auth.user()?.verified ? 'E-Mail verifiziert' : 'E-Mail nicht verifiziert' }}</span>
|
||||||
</div>
|
</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-content>
|
||||||
|
|
||||||
<mat-card-actions align="end">
|
<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()">
|
<button mat-stroked-button type="button" (click)="logout()">
|
||||||
<mat-icon aria-hidden="true">logout</mat-icon>
|
<mat-icon aria-hidden="true">logout</mat-icon>
|
||||||
Logout
|
Logout
|
||||||
|
|||||||
@@ -22,6 +22,16 @@
|
|||||||
color: var(--mat-sys-primary);
|
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) {
|
@media (min-width: 600px) {
|
||||||
.account-page {
|
.account-page {
|
||||||
place-items: center;
|
place-items: center;
|
||||||
|
|||||||
@@ -3,18 +3,25 @@ import { Router } from '@angular/router';
|
|||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatCardModule } from '@angular/material/card';
|
import { MatCardModule } from '@angular/material/card';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
import { AuthService } from '../auth/auth.service';
|
import { AuthService } from '../auth/auth.service';
|
||||||
|
import { OnboardingService } from '../onboarding/onboarding.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-account',
|
selector: 'app-account',
|
||||||
imports: [MatButtonModule, MatCardModule, MatIconModule],
|
imports: [MatButtonModule, MatCardModule, MatIconModule, MatProgressSpinnerModule],
|
||||||
templateUrl: './account.component.html',
|
templateUrl: './account.component.html',
|
||||||
styleUrl: './account.component.scss',
|
styleUrl: './account.component.scss',
|
||||||
})
|
})
|
||||||
export class AccountComponent {
|
export class AccountComponent {
|
||||||
protected readonly auth = inject(AuthService);
|
protected readonly auth = inject(AuthService);
|
||||||
|
protected readonly onboarding = inject(OnboardingService);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
|
|
||||||
|
resetOnboarding(): void {
|
||||||
|
this.onboarding.resetForCurrentUser();
|
||||||
|
}
|
||||||
|
|
||||||
logout(): void {
|
logout(): void {
|
||||||
this.auth.logout();
|
this.auth.logout();
|
||||||
void this.router.navigateByUrl('/login');
|
void this.router.navigateByUrl('/login');
|
||||||
|
|||||||
@@ -123,6 +123,8 @@
|
|||||||
<span>Account</span>
|
<span>Account</span>
|
||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<app-onboarding-overlay />
|
||||||
} @else {
|
} @else {
|
||||||
<main class="app-main auth-main">
|
<main class="app-main auth-main">
|
||||||
<router-outlet />
|
<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 { BreakpointObserver } from '@angular/cdk/layout';
|
||||||
import { toSignal } from '@angular/core/rxjs-interop';
|
import { toSignal } from '@angular/core/rxjs-interop';
|
||||||
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
||||||
@@ -9,6 +9,7 @@ import { MatSidenavModule } from '@angular/material/sidenav';
|
|||||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||||
import { map } from 'rxjs';
|
import { map } from 'rxjs';
|
||||||
import { AuthService } from './auth/auth.service';
|
import { AuthService } from './auth/auth.service';
|
||||||
|
import { OnboardingOverlayComponent } from './onboarding/onboarding-overlay.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
@@ -21,11 +22,12 @@ import { AuthService } from './auth/auth.service';
|
|||||||
MatListModule,
|
MatListModule,
|
||||||
MatSidenavModule,
|
MatSidenavModule,
|
||||||
MatToolbarModule,
|
MatToolbarModule,
|
||||||
|
OnboardingOverlayComponent,
|
||||||
],
|
],
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
styleUrl: './app.scss'
|
styleUrl: './app.scss'
|
||||||
})
|
})
|
||||||
export class App {
|
export class App implements OnInit {
|
||||||
protected readonly auth = inject(AuthService);
|
protected readonly auth = inject(AuthService);
|
||||||
private readonly breakpointObserver = inject(BreakpointObserver);
|
private readonly breakpointObserver = inject(BreakpointObserver);
|
||||||
|
|
||||||
@@ -35,6 +37,12 @@ export class App {
|
|||||||
);
|
);
|
||||||
protected readonly sidebarOpened = signal(false);
|
protected readonly sidebarOpened = signal(false);
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
if (this.auth.isAuthenticated()) {
|
||||||
|
this.auth.loadCurrentUser().subscribe({ error: () => undefined });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected toggleSidebar(): void {
|
protected toggleSidebar(): void {
|
||||||
this.sidebarOpened.update((opened) => !opened);
|
this.sidebarOpened.update((opened) => !opened);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export interface PublicUser {
|
|||||||
email: string;
|
email: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
verified: boolean;
|
verified: boolean;
|
||||||
|
onboardingCompleted: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthTokenResponse {
|
export interface AuthTokenResponse {
|
||||||
|
|||||||
@@ -39,6 +39,18 @@ export class AuthService {
|
|||||||
return this.http.get<VerifyEmailResponse>(`${this.apiUrl}/verify-email`, { params });
|
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 {
|
accessToken(): string | null {
|
||||||
return this.storage?.getItem(ACCESS_TOKEN_KEY) ?? null;
|
return this.storage?.getItem(ACCESS_TOKEN_KEY) ?? null;
|
||||||
}
|
}
|
||||||
@@ -77,8 +89,12 @@ export class AuthService {
|
|||||||
private storeSession(response: AuthTokenResponse): void {
|
private storeSession(response: AuthTokenResponse): void {
|
||||||
this.storage?.setItem(ACCESS_TOKEN_KEY, response.accessToken);
|
this.storage?.setItem(ACCESS_TOKEN_KEY, response.accessToken);
|
||||||
this.storage?.setItem(REFRESH_TOKEN_KEY, response.refreshToken);
|
this.storage?.setItem(REFRESH_TOKEN_KEY, response.refreshToken);
|
||||||
this.storage?.setItem(USER_KEY, JSON.stringify(response.user));
|
this.storeUser(response.user);
|
||||||
this.userSignal.set(response.user);
|
}
|
||||||
|
|
||||||
|
private storeUser(user: PublicUser): void {
|
||||||
|
this.storage?.setItem(USER_KEY, JSON.stringify(user));
|
||||||
|
this.userSignal.set(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
private readStoredUser(): PublicUser | null {
|
private readStoredUser(): PublicUser | null {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
|||||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
import { AuthService } from '../auth.service';
|
import { AuthService } from '../auth.service';
|
||||||
import { getAuthErrorMessage } from '../error-message';
|
import { getAuthErrorMessage } from '../error-message';
|
||||||
|
import { OnboardingService } from '../../onboarding/onboarding.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-login',
|
selector: 'app-login',
|
||||||
@@ -33,6 +34,7 @@ export class LoginComponent {
|
|||||||
private readonly formBuilder = inject(NonNullableFormBuilder);
|
private readonly formBuilder = inject(NonNullableFormBuilder);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
private readonly snackBar = inject(MatSnackBar);
|
private readonly snackBar = inject(MatSnackBar);
|
||||||
|
private readonly onboarding = inject(OnboardingService);
|
||||||
|
|
||||||
protected readonly form = this.formBuilder.group({
|
protected readonly form = this.formBuilder.group({
|
||||||
email: ['', [Validators.required, Validators.email]],
|
email: ['', [Validators.required, Validators.email]],
|
||||||
@@ -54,7 +56,9 @@ export class LoginComponent {
|
|||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.snackBar.open('Login erfolgreich.', 'OK', { duration: 3000 });
|
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) => {
|
error: (error: unknown) => {
|
||||||
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
|
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 { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
import { getAuthErrorMessage } from '../../auth/error-message';
|
import { getAuthErrorMessage } from '../../auth/error-message';
|
||||||
|
import { OnboardingService } from '../../onboarding/onboarding.service';
|
||||||
import { UserList, UserListItem } from '../lists.models';
|
import { UserList, UserListItem } from '../lists.models';
|
||||||
import { ListsService } from '../lists.service';
|
import { ListsService } from '../lists.service';
|
||||||
|
|
||||||
@@ -39,6 +40,7 @@ export class ListDetailComponent implements OnInit {
|
|||||||
private readonly route = inject(ActivatedRoute);
|
private readonly route = inject(ActivatedRoute);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
private readonly snackBar = inject(MatSnackBar);
|
private readonly snackBar = inject(MatSnackBar);
|
||||||
|
private readonly onboarding = inject(OnboardingService);
|
||||||
|
|
||||||
protected readonly list = signal<UserList | null>(null);
|
protected readonly list = signal<UserList | null>(null);
|
||||||
protected readonly isCreateMode = signal(false);
|
protected readonly isCreateMode = signal(false);
|
||||||
@@ -68,6 +70,12 @@ export class ListDetailComponent implements OnInit {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const listId = this.listId();
|
||||||
|
|
||||||
|
if (listId) {
|
||||||
|
this.onboarding.listOpened(listId);
|
||||||
|
}
|
||||||
|
|
||||||
this.loadList();
|
this.loadList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,12 +31,100 @@
|
|||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
} @else if (hasLists()) {
|
} @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">
|
<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 class="template-card" appearance="outlined">
|
||||||
<mat-card-header>
|
<mat-card-header>
|
||||||
<mat-card-title>{{ list.name }}</mat-card-title>
|
<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-header>
|
||||||
|
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
@@ -70,7 +158,11 @@
|
|||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
|
|
||||||
<mat-card-actions align="end">
|
<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>
|
<mat-icon aria-hidden="true">open_in_new</mat-icon>
|
||||||
Oeffnen
|
Oeffnen
|
||||||
</a>
|
</a>
|
||||||
@@ -78,6 +170,7 @@
|
|||||||
</mat-card>
|
</mat-card>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
} @else {
|
} @else {
|
||||||
<mat-card class="state-card" appearance="outlined">
|
<mat-card class="state-card" appearance="outlined">
|
||||||
<mat-card-content>
|
<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 { Component, OnInit, computed, inject, signal } from '@angular/core';
|
||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatButtonToggleModule } from '@angular/material/button-toggle';
|
||||||
import { MatCardModule } from '@angular/material/card';
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
import { getAuthErrorMessage } from '../auth/error-message';
|
import { getAuthErrorMessage } from '../auth/error-message';
|
||||||
|
import { OnboardingService } from '../onboarding/onboarding.service';
|
||||||
import { ListTemplateKind } from '../templates/templates.models';
|
import { ListTemplateKind } from '../templates/templates.models';
|
||||||
import { UserList } from './lists.models';
|
import { UserList } from './lists.models';
|
||||||
import { ListsService } from './lists.service';
|
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({
|
@Component({
|
||||||
selector: 'app-lists',
|
selector: 'app-lists',
|
||||||
imports: [
|
imports: [
|
||||||
DatePipe,
|
DatePipe,
|
||||||
RouterLink,
|
RouterLink,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
|
MatButtonToggleModule,
|
||||||
MatCardModule,
|
MatCardModule,
|
||||||
|
MatFormFieldModule,
|
||||||
MatIconModule,
|
MatIconModule,
|
||||||
|
MatInputModule,
|
||||||
MatProgressSpinnerModule,
|
MatProgressSpinnerModule,
|
||||||
|
MatSelectModule,
|
||||||
],
|
],
|
||||||
templateUrl: './lists.component.html',
|
templateUrl: './lists.component.html',
|
||||||
styleUrl: '../workspace-page.scss',
|
styleUrls: ['../workspace-page.scss', './lists.component.scss'],
|
||||||
})
|
})
|
||||||
export class ListsComponent implements OnInit {
|
export class ListsComponent implements OnInit {
|
||||||
private readonly listsService = inject(ListsService);
|
private readonly listsService = inject(ListsService);
|
||||||
|
protected readonly onboarding = inject(OnboardingService);
|
||||||
|
|
||||||
protected readonly lists = signal<UserList[]>([]);
|
protected readonly lists = signal<UserList[]>([]);
|
||||||
protected readonly loading = signal(true);
|
protected readonly loading = signal(true);
|
||||||
protected readonly errorMessage = signal<string | null>(null);
|
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 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 {
|
ngOnInit(): void {
|
||||||
this.loadLists();
|
this.loadLists();
|
||||||
@@ -65,4 +146,49 @@ export class ListsComponent implements OnInit {
|
|||||||
protected checkedCount(list: UserList): number {
|
protected checkedCount(list: UserList): number {
|
||||||
return list.items.filter((item) => item.checked).length;
|
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>
|
</div>
|
||||||
@if (canEditItems()) {
|
@if (canEditItems()) {
|
||||||
<div class="detail-actions">
|
<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()) {
|
@if (copyingTemplate()) {
|
||||||
<mat-progress-spinner mode="indeterminate" diameter="18" />
|
<mat-progress-spinner mode="indeterminate" diameter="18" />
|
||||||
} @else {
|
} @else {
|
||||||
@@ -48,7 +54,7 @@
|
|||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
} @else {
|
} @else {
|
||||||
<mat-card class="editor-card" appearance="outlined">
|
<mat-card class="editor-card" appearance="outlined" data-onboarding="template-details">
|
||||||
<mat-card-header>
|
<mat-card-header>
|
||||||
<mat-card-title>Details</mat-card-title>
|
<mat-card-title>Details</mat-card-title>
|
||||||
</mat-card-header>
|
</mat-card-header>
|
||||||
@@ -80,7 +86,7 @@
|
|||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
</mat-card>
|
</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-header>
|
||||||
<mat-card-title>Items</mat-card-title>
|
<mat-card-title>Items</mat-card-title>
|
||||||
<mat-card-subtitle>
|
<mat-card-subtitle>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { MatInputModule } from '@angular/material/input';
|
|||||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
import { getAuthErrorMessage } from '../../auth/error-message';
|
import { getAuthErrorMessage } from '../../auth/error-message';
|
||||||
|
import { OnboardingService } from '../../onboarding/onboarding.service';
|
||||||
import { ConfirmDeleteDialogComponent } from '../confirm-delete-dialog/confirm-delete-dialog.component';
|
import { ConfirmDeleteDialogComponent } from '../confirm-delete-dialog/confirm-delete-dialog.component';
|
||||||
import { ListTemplate } from '../templates.models';
|
import { ListTemplate } from '../templates.models';
|
||||||
import { TemplatesService } from '../templates.service';
|
import { TemplatesService } from '../templates.service';
|
||||||
@@ -43,6 +44,7 @@ export class TemplateDetailComponent implements OnInit {
|
|||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
private readonly snackBar = inject(MatSnackBar);
|
private readonly snackBar = inject(MatSnackBar);
|
||||||
private readonly templatesService = inject(TemplatesService);
|
private readonly templatesService = inject(TemplatesService);
|
||||||
|
protected readonly onboarding = inject(OnboardingService);
|
||||||
|
|
||||||
protected readonly template = signal<ListTemplate | null>(null);
|
protected readonly template = signal<ListTemplate | null>(null);
|
||||||
protected readonly isCreateMode = signal(false);
|
protected readonly isCreateMode = signal(false);
|
||||||
@@ -104,6 +106,7 @@ export class TemplateDetailComponent implements OnInit {
|
|||||||
|
|
||||||
protected saveTemplate(): void {
|
protected saveTemplate(): void {
|
||||||
const templateId = this.templateId();
|
const templateId = this.templateId();
|
||||||
|
const creatingTemplate = this.isCreateMode() || !templateId;
|
||||||
|
|
||||||
if (this.templateForm.invalid) {
|
if (this.templateForm.invalid) {
|
||||||
this.templateForm.markAllAsTouched();
|
this.templateForm.markAllAsTouched();
|
||||||
@@ -127,11 +130,12 @@ export class TemplateDetailComponent implements OnInit {
|
|||||||
.subscribe({
|
.subscribe({
|
||||||
next: (template) => {
|
next: (template) => {
|
||||||
this.setTemplate(template);
|
this.setTemplate(template);
|
||||||
if (this.isCreateMode()) {
|
if (creatingTemplate) {
|
||||||
this.isCreateMode.set(false);
|
this.isCreateMode.set(false);
|
||||||
void this.router.navigate(['/templates', template.id], {
|
void this.router.navigate(['/templates', template.id], {
|
||||||
replaceUrl: true,
|
replaceUrl: true,
|
||||||
});
|
});
|
||||||
|
this.onboarding.templateCreated(template.id);
|
||||||
}
|
}
|
||||||
this.snackBar.open('Template gespeichert.', 'OK', { duration: 2500 });
|
this.snackBar.open('Template gespeichert.', 'OK', { duration: 2500 });
|
||||||
},
|
},
|
||||||
@@ -162,6 +166,7 @@ export class TemplateDetailComponent implements OnInit {
|
|||||||
next: (template) => {
|
next: (template) => {
|
||||||
this.setTemplate(template);
|
this.setTemplate(template);
|
||||||
this.itemForm.reset({ title: '', required: true });
|
this.itemForm.reset({ title: '', required: true });
|
||||||
|
this.onboarding.templateItemAdded();
|
||||||
},
|
},
|
||||||
error: (error: unknown) => {
|
error: (error: unknown) => {
|
||||||
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
|
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
|
||||||
@@ -245,10 +250,11 @@ export class TemplateDetailComponent implements OnInit {
|
|||||||
.createListFromTemplate(templateId)
|
.createListFromTemplate(templateId)
|
||||||
.pipe(finalize(() => this.copyingTemplate.set(false)))
|
.pipe(finalize(() => this.copyingTemplate.set(false)))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: (list) => {
|
||||||
this.snackBar.open('Liste aus Template erstellt.', 'OK', {
|
this.snackBar.open('Liste aus Template erstellt.', 'OK', {
|
||||||
duration: 3000,
|
duration: 3000,
|
||||||
});
|
});
|
||||||
|
this.onboarding.templateCopiedToList(list.id);
|
||||||
void this.router.navigateByUrl('/lists');
|
void this.router.navigateByUrl('/lists');
|
||||||
},
|
},
|
||||||
error: (error: unknown) => {
|
error: (error: unknown) => {
|
||||||
|
|||||||
@@ -5,7 +5,12 @@
|
|||||||
<p>Vorlagen fuer wiederkehrende Listen.</p>
|
<p>Vorlagen fuer wiederkehrende Listen.</p>
|
||||||
</div>
|
</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>
|
<mat-icon aria-hidden="true">add</mat-icon>
|
||||||
Neues Template
|
Neues Template
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { MatIconModule } from '@angular/material/icon';
|
|||||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
import { getAuthErrorMessage } from '../auth/error-message';
|
import { getAuthErrorMessage } from '../auth/error-message';
|
||||||
|
import { OnboardingService } from '../onboarding/onboarding.service';
|
||||||
import { ConfirmDeleteDialogComponent } from './confirm-delete-dialog/confirm-delete-dialog.component';
|
import { ConfirmDeleteDialogComponent } from './confirm-delete-dialog/confirm-delete-dialog.component';
|
||||||
import { ListTemplate, ListTemplateKind } from './templates.models';
|
import { ListTemplate, ListTemplateKind } from './templates.models';
|
||||||
import { TemplatesService } from './templates.service';
|
import { TemplatesService } from './templates.service';
|
||||||
@@ -33,6 +34,7 @@ export class TemplatesComponent implements OnInit {
|
|||||||
private readonly dialog = inject(MatDialog);
|
private readonly dialog = inject(MatDialog);
|
||||||
private readonly snackBar = inject(MatSnackBar);
|
private readonly snackBar = inject(MatSnackBar);
|
||||||
private readonly templatesService = inject(TemplatesService);
|
private readonly templatesService = inject(TemplatesService);
|
||||||
|
protected readonly onboarding = inject(OnboardingService);
|
||||||
|
|
||||||
protected readonly templates = signal<ListTemplate[]>([]);
|
protected readonly templates = signal<ListTemplate[]>([]);
|
||||||
protected readonly loading = signal(true);
|
protected readonly loading = signal(true);
|
||||||
|
|||||||
Reference in New Issue
Block a user