This commit is contained in:
Bastian Wagner
2026-06-09 09:45:33 +02:00
commit 537c7cbbee
124 changed files with 27283 additions and 0 deletions

View File

@@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false

44
listify-client/.gitignore vendored Normal file
View File

@@ -0,0 +1,44 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/mcp.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
__screenshots__/
# System files
.DS_Store
Thumbs.db

View File

@@ -0,0 +1,12 @@
{
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
}

View File

@@ -0,0 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}

20
listify-client/.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,20 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}

9
listify-client/.vscode/mcp.json vendored Normal file
View File

@@ -0,0 +1,9 @@
{
// For more information, visit: https://angular.dev/ai/mcp
"servers": {
"angular-cli": {
"command": "npx",
"args": ["-y", "@angular/cli", "mcp"]
}
}
}

42
listify-client/.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,42 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
}
]
}

59
listify-client/README.md Normal file
View File

@@ -0,0 +1,59 @@
# ListifyClient
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.2.6.
## Development server
To start a local development server, run:
```bash
ng serve
```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
## Code scaffolding
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
ng generate component component-name
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```bash
ng generate --help
```
## Building
To build the project run:
```bash
ng build
```
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.

View File

@@ -0,0 +1,82 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm",
"analytics": "2085696e-41a3-455d-be66-774ca34f4584"
},
"newProjectRoot": "projects",
"projects": {
"listify-client": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.scss"
]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"options": {
"proxyConfig": "proxy.conf.json"
},
"configurations": {
"production": {
"buildTarget": "listify-client:build:production"
},
"development": {
"buildTarget": "listify-client:build:development"
}
},
"defaultConfiguration": "development"
},
"test": {
"builder": "@angular/build:unit-test"
}
}
}
}
}

8725
listify-client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,34 @@
{
"name": "listify-client",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"packageManager": "npm@11.12.1",
"dependencies": {
"@angular/cdk": "^21.2.14",
"@angular/common": "^21.2.0",
"@angular/compiler": "^21.2.0",
"@angular/core": "^21.2.0",
"@angular/forms": "^21.2.0",
"@angular/material": "^21.2.14",
"@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
},
"devDependencies": {
"@angular/build": "^21.2.6",
"@angular/cli": "^21.2.6",
"@angular/compiler-cli": "^21.2.0",
"jsdom": "^28.0.0",
"prettier": "^3.8.1",
"typescript": "~5.9.2",
"vitest": "^4.0.8"
}
}

View File

@@ -0,0 +1,10 @@
{
"/api": {
"target": "http://localhost:3000",
"secure": false,
"changeOrigin": true,
"pathRewrite": {
"^/api": ""
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,22 @@
<section class="account-page">
<mat-card class="account-card" appearance="outlined">
<mat-card-header>
<mat-card-title>Willkommen, {{ auth.user()?.name || auth.user()?.email }}</mat-card-title>
<mat-card-subtitle>{{ auth.user()?.email }}</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
<div class="status-row">
<mat-icon aria-hidden="true">verified_user</mat-icon>
<span>{{ auth.user()?.verified ? 'E-Mail verifiziert' : 'E-Mail nicht verifiziert' }}</span>
</div>
</mat-card-content>
<mat-card-actions align="end">
<button mat-stroked-button type="button" (click)="logout()">
<mat-icon aria-hidden="true">logout</mat-icon>
Logout
</button>
</mat-card-actions>
</mat-card>
</section>

View File

@@ -0,0 +1,30 @@
.account-page {
min-height: inherit;
display: grid;
align-items: start;
padding: 1rem;
}
.account-card {
width: min(100%, 520px);
border-radius: 8px;
}
.status-row {
display: flex;
align-items: center;
gap: 0.75rem;
margin-top: 1rem;
color: var(--mat-sys-on-surface-variant);
}
.status-row mat-icon {
color: var(--mat-sys-primary);
}
@media (min-width: 600px) {
.account-page {
place-items: center;
padding: 2rem 1rem;
}
}

View File

@@ -0,0 +1,22 @@
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
import { AuthService } from '../auth/auth.service';
@Component({
selector: 'app-account',
imports: [MatButtonModule, MatCardModule, MatIconModule],
templateUrl: './account.component.html',
styleUrl: './account.component.scss',
})
export class AccountComponent {
protected readonly auth = inject(AuthService);
private readonly router = inject(Router);
logout(): void {
this.auth.logout();
void this.router.navigateByUrl('/login');
}
}

View File

@@ -0,0 +1,14 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { authInterceptor } from './auth/auth.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideHttpClient(withInterceptors([authInterceptor])),
provideRouter(routes)
]
};

View File

@@ -0,0 +1,130 @@
<mat-toolbar class="app-toolbar">
@if (auth.isAuthenticated()) {
<button
mat-icon-button
type="button"
class="menu-button"
aria-label="Menue oeffnen"
(click)="toggleSidebar()"
>
<mat-icon aria-hidden="true">menu</mat-icon>
</button>
}
<a class="brand" [routerLink]="auth.isAuthenticated() ? '/lists' : '/login'" aria-label="Listify Startseite">
<mat-icon aria-hidden="true">checklist</mat-icon>
<span>Listify</span>
</a>
<span class="spacer"></span>
@if (auth.isAuthenticated()) {
<span class="toolbar-user">{{ auth.user()?.name || auth.user()?.email }}</span>
} @else {
<a
mat-button
routerLink="/login"
routerLinkActive="active-link"
ariaCurrentWhenActive="page"
>
<mat-icon aria-hidden="true">login</mat-icon>
Login
</a>
<a
mat-flat-button
routerLink="/register"
routerLinkActive="active-link"
ariaCurrentWhenActive="page"
>
<mat-icon aria-hidden="true">person_add</mat-icon>
Registrieren
</a>
}
</mat-toolbar>
@if (auth.isAuthenticated()) {
<mat-sidenav-container class="shell">
<mat-sidenav
class="sidebar"
[mode]="isCompact() ? 'over' : 'side'"
[opened]="!isCompact() || sidebarOpened()"
fixedInViewport
[fixedTopGap]="isCompact() ? 56 : 64"
>
<nav aria-label="Hauptnavigation">
<mat-nav-list>
<a
mat-list-item
routerLink="/templates"
routerLinkActive="active-nav-link"
ariaCurrentWhenActive="page"
(click)="closeSidebarOnCompact()"
>
<mat-icon matListItemIcon aria-hidden="true">dashboard_customize</mat-icon>
<span matListItemTitle>Templates</span>
</a>
<a
mat-list-item
routerLink="/lists"
routerLinkActive="active-nav-link"
ariaCurrentWhenActive="page"
(click)="closeSidebarOnCompact()"
>
<mat-icon matListItemIcon aria-hidden="true">format_list_bulleted</mat-icon>
<span matListItemTitle>Listen</span>
</a>
<a
mat-list-item
routerLink="/account"
routerLinkActive="active-nav-link"
ariaCurrentWhenActive="page"
(click)="closeSidebarOnCompact()"
>
<mat-icon matListItemIcon aria-hidden="true">account_circle</mat-icon>
<span matListItemTitle>Account</span>
</a>
</mat-nav-list>
</nav>
</mat-sidenav>
<mat-sidenav-content class="shell-content">
<main class="app-main">
<router-outlet />
</main>
</mat-sidenav-content>
</mat-sidenav-container>
<nav class="bottom-nav" aria-label="Mobile Hauptnavigation">
<a
class="bottom-nav-link"
routerLink="/templates"
routerLinkActive="active-bottom-link"
ariaCurrentWhenActive="page"
>
<mat-icon aria-hidden="true">dashboard_customize</mat-icon>
<span>Templates</span>
</a>
<a
class="bottom-nav-link"
routerLink="/lists"
routerLinkActive="active-bottom-link"
ariaCurrentWhenActive="page"
>
<mat-icon aria-hidden="true">format_list_bulleted</mat-icon>
<span>Listen</span>
</a>
<a
class="bottom-nav-link"
routerLink="/account"
routerLinkActive="active-bottom-link"
ariaCurrentWhenActive="page"
>
<mat-icon aria-hidden="true">account_circle</mat-icon>
<span>Account</span>
</a>
</nav>
} @else {
<main class="app-main auth-main">
<router-outlet />
</main>
}

View File

@@ -0,0 +1,38 @@
import { Routes } from '@angular/router';
import { authGuard } from './auth/auth.guard';
import { LoginComponent } from './auth/login/login.component';
import { RegisterComponent } from './auth/register/register.component';
import { AccountComponent } from './account/account.component';
import { VerifyEmailComponent } from './auth/verify-email/verify-email.component';
import { ListDetailComponent } from './lists/list-detail/list-detail.component';
import { ListsComponent } from './lists/lists.component';
import { TemplatesComponent } from './templates/templates.component';
import { TemplateDetailComponent } from './templates/template-detail/template-detail.component';
export const routes: Routes = [
{ path: '', pathMatch: 'full', redirectTo: 'login' },
{ path: 'login', component: LoginComponent },
{ path: 'register', component: RegisterComponent },
{ path: 'verify-email', component: VerifyEmailComponent },
{
path: 'auth',
children: [{ path: 'verify-email', component: VerifyEmailComponent }],
},
{ path: 'templates', component: TemplatesComponent, canActivate: [authGuard] },
{
path: 'templates/new',
component: TemplateDetailComponent,
canActivate: [authGuard],
},
{
path: 'templates/:templateId',
component: TemplateDetailComponent,
canActivate: [authGuard],
},
{ path: 'lists', component: ListsComponent, canActivate: [authGuard] },
{ path: 'lists/new', component: ListDetailComponent, canActivate: [authGuard] },
{ path: 'lists/:listId', component: ListDetailComponent, canActivate: [authGuard] },
{ path: 'listen', redirectTo: 'lists' },
{ path: 'account', component: AccountComponent, canActivate: [authGuard] },
{ path: '**', redirectTo: 'login' },
];

View File

@@ -0,0 +1,198 @@
:host {
display: block;
min-height: 100dvh;
background:
linear-gradient(135deg, rgba(20, 105, 84, 0.12), transparent 36%),
linear-gradient(315deg, rgba(123, 92, 40, 0.12), transparent 34%),
var(--mat-sys-surface);
}
.app-toolbar {
position: sticky;
top: 0;
z-index: 10;
height: 56px;
min-height: 56px;
padding-inline: 0.75rem;
border-bottom: 1px solid var(--mat-sys-outline-variant);
background: color-mix(in srgb, var(--mat-sys-surface) 92%, transparent);
color: var(--mat-sys-on-surface);
backdrop-filter: blur(14px);
}
.brand {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: inherit;
font-weight: 600;
min-width: 0;
text-decoration: none;
}
.brand mat-icon {
color: var(--mat-sys-primary);
}
.menu-button {
flex: 0 0 auto;
margin-right: 0.125rem;
}
.spacer {
flex: 1 1 auto;
}
.toolbar-user {
overflow: hidden;
max-width: 42vw;
color: var(--mat-sys-on-surface-variant);
font-size: 0.8125rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-toolbar a[mat-button],
.app-toolbar a[mat-flat-button] {
margin-left: 0.25rem;
}
.active-link {
color: var(--mat-sys-primary);
}
.shell {
min-height: calc(100dvh - 56px);
background: transparent;
}
.sidebar {
width: min(82vw, 280px);
border-right: 1px solid var(--mat-sys-outline-variant);
background: color-mix(in srgb, var(--mat-sys-surface-container-low) 96%, white);
}
.sidebar nav {
padding: 0.75rem;
}
.sidebar a[mat-list-item] {
margin-bottom: 0.25rem;
border-radius: 8px;
}
.active-nav-link {
background: color-mix(in srgb, var(--mat-sys-primary) 12%, transparent);
color: var(--mat-sys-primary);
}
.shell-content {
min-height: calc(100dvh - 56px);
}
.app-main {
min-height: calc(100dvh - 56px);
}
.shell .app-main {
padding-bottom: calc(76px + env(safe-area-inset-bottom));
}
.auth-main {
display: block;
}
.bottom-nav {
position: fixed;
right: 0;
bottom: 0;
left: 0;
z-index: 20;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.25rem;
padding: 0.35rem 0.5rem calc(0.35rem + env(safe-area-inset-bottom));
border-top: 1px solid var(--mat-sys-outline-variant);
background: color-mix(in srgb, var(--mat-sys-surface) 96%, transparent);
backdrop-filter: blur(14px);
}
.bottom-nav-link {
display: grid;
justify-items: center;
gap: 0.125rem;
min-width: 0;
min-height: 56px;
padding: 0.35rem 0.25rem;
border-radius: 8px;
color: var(--mat-sys-on-surface-variant);
font-size: 0.75rem;
line-height: 1;
text-decoration: none;
}
.bottom-nav-link mat-icon {
width: 24px;
height: 24px;
font-size: 24px;
}
.bottom-nav-link span {
overflow: hidden;
max-width: 100%;
text-overflow: ellipsis;
white-space: nowrap;
}
.active-bottom-link {
background: color-mix(in srgb, var(--mat-sys-primary) 12%, transparent);
color: var(--mat-sys-primary);
}
@media (max-width: 420px) {
.app-toolbar a[mat-button],
.app-toolbar a[mat-flat-button] {
min-width: 0;
padding-inline: 0.5rem;
}
.app-toolbar a[mat-flat-button] mat-icon,
.app-toolbar a[mat-button] mat-icon {
display: none;
}
}
@media (min-width: 801px) {
.app-toolbar {
height: 64px;
min-height: 64px;
padding-inline: 1rem;
}
.menu-button {
display: none;
}
.toolbar-user {
max-width: min(40vw, 360px);
font-size: 0.9rem;
}
.shell,
.shell-content,
.app-main {
min-height: calc(100dvh - 64px);
}
.shell .app-main {
padding-bottom: 0;
}
.sidebar {
width: 248px;
}
.bottom-nav {
display: none;
}
}

View File

@@ -0,0 +1,25 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { App } from './app';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
providers: [provideRouter([])],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it('should render brand', async () => {
const fixture = TestBed.createComponent(App);
await fixture.whenStable();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.brand')?.textContent).toContain('Listify');
});
});

View File

@@ -0,0 +1,47 @@
import { Component, 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';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import { MatSidenavModule } from '@angular/material/sidenav';
import { MatToolbarModule } from '@angular/material/toolbar';
import { map } from 'rxjs';
import { AuthService } from './auth/auth.service';
@Component({
selector: 'app-root',
imports: [
RouterOutlet,
RouterLink,
RouterLinkActive,
MatButtonModule,
MatIconModule,
MatListModule,
MatSidenavModule,
MatToolbarModule,
],
templateUrl: './app.html',
styleUrl: './app.scss'
})
export class App {
protected readonly auth = inject(AuthService);
private readonly breakpointObserver = inject(BreakpointObserver);
protected readonly isCompact = toSignal(
this.breakpointObserver.observe('(max-width: 800px)').pipe(map((state) => state.matches)),
{ initialValue: false },
);
protected readonly sidebarOpened = signal(false);
protected toggleSidebar(): void {
this.sidebarOpened.update((opened) => !opened);
}
protected closeSidebarOnCompact(): void {
if (this.isCompact()) {
this.sidebarOpened.set(false);
}
}
}

View File

@@ -0,0 +1,78 @@
.auth-page {
min-height: inherit;
display: grid;
align-items: start;
padding: 1rem;
}
.auth-card {
width: min(100%, 440px);
border-radius: 8px;
background: color-mix(in srgb, var(--mat-sys-surface-container-low) 94%, white);
}
.auth-card mat-card-header {
padding-bottom: 0.75rem;
}
.auth-form {
display: grid;
gap: 0.65rem;
padding-top: 0.75rem;
}
.auth-form mat-form-field {
width: 100%;
}
.auth-form button[type='submit'] {
min-height: 48px;
}
.auth-form mat-progress-spinner {
display: inline-flex;
margin-right: 0.5rem;
}
.verify-card mat-card-content {
padding-top: 1rem;
}
.verification-state {
display: grid;
justify-items: center;
gap: 1rem;
min-height: 132px;
padding: 1rem 0.5rem;
text-align: center;
color: var(--mat-sys-on-surface-variant);
}
.verification-state p {
margin: 0;
}
.verification-state .state-icon {
width: 44px;
height: 44px;
font-size: 44px;
}
.verification-state.success .state-icon {
color: var(--mat-sys-primary);
}
.verification-state.error .state-icon {
color: var(--mat-sys-error);
}
@media (min-width: 481px) {
.auth-page {
place-items: center;
padding: 2rem 1rem;
}
.auth-form {
gap: 0.75rem;
}
}

View File

@@ -0,0 +1,10 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from './auth.service';
export const authGuard: CanActivateFn = () => {
const auth = inject(AuthService);
const router = inject(Router);
return auth.isAuthenticated() ? true : router.parseUrl('/login');
};

View File

@@ -0,0 +1,71 @@
import {
HttpErrorResponse,
HttpEvent,
HttpHandlerFn,
HttpInterceptorFn,
HttpRequest,
} from '@angular/common/http';
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { Observable, catchError, switchMap, throwError } from 'rxjs';
import { AuthService } from './auth.service';
export const authInterceptor: HttpInterceptorFn = (
request: HttpRequest<unknown>,
next: HttpHandlerFn,
): Observable<HttpEvent<unknown>> => {
const auth = inject(AuthService);
const router = inject(Router);
const authenticatedRequest = withAccessToken(request, auth.accessToken());
return next(authenticatedRequest).pipe(
catchError((error: unknown) => {
if (!shouldRefresh(request, error)) {
return throwError(() => error);
}
return auth.refreshSession().pipe(
switchMap((response) =>
next(withAccessToken(request, response.accessToken)),
),
catchError((refreshError: unknown) => {
auth.logout();
void router.navigateByUrl('/login');
return throwError(() => refreshError);
}),
);
}),
);
};
function withAccessToken(
request: HttpRequest<unknown>,
accessToken: string | null,
): HttpRequest<unknown> {
if (!accessToken || !isApiRequest(request) || isAuthRequest(request)) {
return request;
}
return request.clone({
setHeaders: {
Authorization: `Bearer ${accessToken}`,
},
});
}
function shouldRefresh(request: HttpRequest<unknown>, error: unknown): boolean {
return (
isApiRequest(request) &&
!isAuthRequest(request) &&
error instanceof HttpErrorResponse &&
error.status === 401
);
}
function isApiRequest(request: HttpRequest<unknown>): boolean {
return request.url.startsWith('/api/');
}
function isAuthRequest(request: HttpRequest<unknown>): boolean {
return request.url.startsWith('/api/auth/');
}

View File

@@ -0,0 +1,31 @@
export interface PublicUser {
id: string;
email: string;
name?: string;
verified: boolean;
}
export interface AuthTokenResponse {
accessToken: string;
refreshToken: string;
user: PublicUser;
}
export interface RegisterResponse {
message: string;
user: PublicUser;
}
export interface VerifyEmailResponse {
message: string;
user: PublicUser;
}
export interface LoginRequest {
email: string;
password: string;
}
export interface RegisterRequest extends LoginRequest {
name?: string;
}

View File

@@ -0,0 +1,102 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, computed, inject, signal } from '@angular/core';
import { Observable, finalize, shareReplay, tap, throwError } from 'rxjs';
import {
AuthTokenResponse,
LoginRequest,
PublicUser,
RegisterRequest,
RegisterResponse,
VerifyEmailResponse,
} from './auth.models';
const ACCESS_TOKEN_KEY = 'listify.accessToken';
const REFRESH_TOKEN_KEY = 'listify.refreshToken';
const USER_KEY = 'listify.user';
@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly http = inject(HttpClient);
private readonly apiUrl = '/api/auth';
private readonly userSignal = signal<PublicUser | null>(this.readStoredUser());
private refreshRequest$: Observable<AuthTokenResponse> | null = null;
readonly user = this.userSignal.asReadonly();
readonly isAuthenticated = computed(() => Boolean(this.userSignal()));
login(credentials: LoginRequest): Observable<AuthTokenResponse> {
return this.http
.post<AuthTokenResponse>(`${this.apiUrl}/login`, credentials)
.pipe(tap((response) => this.storeSession(response)));
}
register(data: RegisterRequest): Observable<RegisterResponse> {
return this.http.post<RegisterResponse>(`${this.apiUrl}/register`, data);
}
verifyEmail(token: string): Observable<VerifyEmailResponse> {
const params = new HttpParams().set('token', token);
return this.http.get<VerifyEmailResponse>(`${this.apiUrl}/verify-email`, { params });
}
accessToken(): string | null {
return this.storage?.getItem(ACCESS_TOKEN_KEY) ?? null;
}
refreshToken(): string | null {
return this.storage?.getItem(REFRESH_TOKEN_KEY) ?? null;
}
refreshSession(): Observable<AuthTokenResponse> {
const refreshToken = this.refreshToken();
if (!refreshToken) {
return throwError(() => new Error('Refresh token is missing.'));
}
this.refreshRequest$ ??= this.http
.post<AuthTokenResponse>(`${this.apiUrl}/refresh`, { refreshToken })
.pipe(
tap((response) => this.storeSession(response)),
finalize(() => {
this.refreshRequest$ = null;
}),
shareReplay({ bufferSize: 1, refCount: true }),
);
return this.refreshRequest$;
}
logout(): void {
this.storage?.removeItem(ACCESS_TOKEN_KEY);
this.storage?.removeItem(REFRESH_TOKEN_KEY);
this.storage?.removeItem(USER_KEY);
this.userSignal.set(null);
}
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);
}
private readStoredUser(): PublicUser | null {
const rawUser = this.storage?.getItem(USER_KEY);
if (!rawUser) {
return null;
}
try {
return JSON.parse(rawUser) as PublicUser;
} catch {
this.storage?.removeItem(USER_KEY);
return null;
}
}
private get storage(): Storage | null {
return typeof window === 'undefined' ? null : window.localStorage;
}
}

View File

@@ -0,0 +1,24 @@
import { HttpErrorResponse } from '@angular/common/http';
type ApiErrorBody = {
message?: string | string[];
error?: string;
};
export function getAuthErrorMessage(error: unknown): string {
if (error instanceof HttpErrorResponse) {
const body = error.error as ApiErrorBody | string | undefined;
if (typeof body === 'string') {
return body;
}
if (Array.isArray(body?.message)) {
return body.message.join(' ');
}
return body?.message ?? body?.error ?? 'Die Anfrage konnte nicht verarbeitet werden.';
}
return 'Die Anfrage konnte nicht verarbeitet werden.';
}

View File

@@ -0,0 +1,60 @@
<section class="auth-page">
<mat-card class="auth-card" appearance="outlined">
<mat-card-header>
<mat-card-title>Login</mat-card-title>
<mat-card-subtitle>Mit deinem Listify-Konto anmelden</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
<form [formGroup]="form" (ngSubmit)="submit()" class="auth-form">
<mat-form-field appearance="outline">
<mat-label>E-Mail</mat-label>
<input matInput type="email" formControlName="email" autocomplete="email" />
<mat-icon matSuffix aria-hidden="true">mail</mat-icon>
@if (form.controls.email.hasError('required')) {
<mat-error>E-Mail ist erforderlich.</mat-error>
} @else if (form.controls.email.hasError('email')) {
<mat-error>Bitte gib eine gueltige E-Mail ein.</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Passwort</mat-label>
<input
matInput
[type]="hidePassword ? 'password' : 'text'"
formControlName="password"
autocomplete="current-password"
/>
<button
mat-icon-button
matSuffix
type="button"
[attr.aria-label]="hidePassword ? 'Passwort anzeigen' : 'Passwort verbergen'"
(click)="hidePassword = !hidePassword"
>
<mat-icon aria-hidden="true">{{ hidePassword ? 'visibility' : 'visibility_off' }}</mat-icon>
</button>
@if (form.controls.password.hasError('required')) {
<mat-error>Passwort ist erforderlich.</mat-error>
} @else if (form.controls.password.hasError('minlength')) {
<mat-error>Mindestens 8 Zeichen.</mat-error>
}
</mat-form-field>
<button mat-flat-button color="primary" type="submit" [disabled]="loading">
@if (loading) {
<mat-progress-spinner mode="indeterminate" diameter="18" />
} @else {
<mat-icon aria-hidden="true">login</mat-icon>
}
Einloggen
</button>
</form>
</mat-card-content>
<mat-card-actions align="end">
<a mat-button routerLink="/register">Konto erstellen</a>
</mat-card-actions>
</mat-card>
</section>

View File

@@ -0,0 +1,64 @@
import { Component, inject } from '@angular/core';
import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import { finalize } from 'rxjs';
import { MatButtonModule } from '@angular/material/button';
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 { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { AuthService } from '../auth.service';
import { getAuthErrorMessage } from '../error-message';
@Component({
selector: 'app-login',
imports: [
ReactiveFormsModule,
RouterLink,
MatButtonModule,
MatCardModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatProgressSpinnerModule,
MatSnackBarModule,
],
templateUrl: './login.component.html',
styleUrl: '../auth-page.scss',
})
export class LoginComponent {
private readonly auth = inject(AuthService);
private readonly formBuilder = inject(NonNullableFormBuilder);
private readonly router = inject(Router);
private readonly snackBar = inject(MatSnackBar);
protected readonly form = this.formBuilder.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
});
protected loading = false;
protected hidePassword = true;
submit(): void {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
this.loading = true;
this.auth
.login(this.form.getRawValue())
.pipe(finalize(() => (this.loading = false)))
.subscribe({
next: () => {
this.snackBar.open('Login erfolgreich.', 'OK', { duration: 3000 });
void this.router.navigateByUrl('/account');
},
error: (error: unknown) => {
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
},
});
}
}

View File

@@ -0,0 +1,66 @@
<section class="auth-page">
<mat-card class="auth-card" appearance="outlined">
<mat-card-header>
<mat-card-title>Registrieren</mat-card-title>
<mat-card-subtitle>Neues Listify-Konto anlegen</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
<form [formGroup]="form" (ngSubmit)="submit()" class="auth-form">
<mat-form-field appearance="outline">
<mat-label>Name</mat-label>
<input matInput type="text" formControlName="name" autocomplete="name" />
<mat-icon matSuffix aria-hidden="true">badge</mat-icon>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>E-Mail</mat-label>
<input matInput type="email" formControlName="email" autocomplete="email" />
<mat-icon matSuffix aria-hidden="true">mail</mat-icon>
@if (form.controls.email.hasError('required')) {
<mat-error>E-Mail ist erforderlich.</mat-error>
} @else if (form.controls.email.hasError('email')) {
<mat-error>Bitte gib eine gueltige E-Mail ein.</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Passwort</mat-label>
<input
matInput
[type]="hidePassword ? 'password' : 'text'"
formControlName="password"
autocomplete="new-password"
/>
<button
mat-icon-button
matSuffix
type="button"
[attr.aria-label]="hidePassword ? 'Passwort anzeigen' : 'Passwort verbergen'"
(click)="hidePassword = !hidePassword"
>
<mat-icon aria-hidden="true">{{ hidePassword ? 'visibility' : 'visibility_off' }}</mat-icon>
</button>
@if (form.controls.password.hasError('required')) {
<mat-error>Passwort ist erforderlich.</mat-error>
} @else if (form.controls.password.hasError('minlength')) {
<mat-error>Mindestens 8 Zeichen.</mat-error>
}
</mat-form-field>
<button mat-flat-button color="primary" type="submit" [disabled]="loading">
@if (loading) {
<mat-progress-spinner mode="indeterminate" diameter="18" />
} @else {
<mat-icon aria-hidden="true">person_add</mat-icon>
}
Registrieren
</button>
</form>
</mat-card-content>
<mat-card-actions align="end">
<a mat-button routerLink="/login">Zum Login</a>
</mat-card-actions>
</mat-card>
</section>

View File

@@ -0,0 +1,65 @@
import { Component, inject } from '@angular/core';
import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import { finalize } from 'rxjs';
import { MatButtonModule } from '@angular/material/button';
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 { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { AuthService } from '../auth.service';
import { getAuthErrorMessage } from '../error-message';
@Component({
selector: 'app-register',
imports: [
ReactiveFormsModule,
RouterLink,
MatButtonModule,
MatCardModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatProgressSpinnerModule,
MatSnackBarModule,
],
templateUrl: './register.component.html',
styleUrl: '../auth-page.scss',
})
export class RegisterComponent {
private readonly auth = inject(AuthService);
private readonly formBuilder = inject(NonNullableFormBuilder);
private readonly router = inject(Router);
private readonly snackBar = inject(MatSnackBar);
protected readonly form = this.formBuilder.group({
name: [''],
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
});
protected loading = false;
protected hidePassword = true;
submit(): void {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
this.loading = true;
this.auth
.register(this.form.getRawValue())
.pipe(finalize(() => (this.loading = false)))
.subscribe({
next: (response) => {
this.snackBar.open(response.message, 'OK', { duration: 5000 });
void this.router.navigateByUrl('/login');
},
error: (error: unknown) => {
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
},
});
}
}

View File

@@ -0,0 +1,34 @@
<section class="auth-page">
<mat-card class="auth-card verify-card" appearance="outlined">
<mat-card-header>
<mat-card-title>E-Mail-Verifikation</mat-card-title>
<mat-card-subtitle>{{ email() || 'Listify-Konto bestaetigen' }}</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
<div class="verification-state" [class.success]="state() === 'success'" [class.error]="state() === 'error' || state() === 'missing-token'">
@if (state() === 'loading') {
<mat-progress-spinner mode="indeterminate" diameter="44" />
} @else if (state() === 'success') {
<mat-icon class="state-icon" aria-hidden="true">mark_email_read</mat-icon>
} @else {
<mat-icon class="state-icon" aria-hidden="true">error</mat-icon>
}
<p>{{ message() }}</p>
</div>
</mat-card-content>
<mat-card-actions align="end">
@if (state() === 'success') {
<a mat-flat-button routerLink="/login">
<mat-icon aria-hidden="true">login</mat-icon>
Zum Login
</a>
} @else if (state() !== 'loading') {
<a mat-button routerLink="/register">Neu registrieren</a>
<a mat-flat-button routerLink="/login">Zum Login</a>
}
</mat-card-actions>
</mat-card>
</section>

View File

@@ -0,0 +1,53 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { ActivatedRoute, RouterLink } 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.service';
import { getAuthErrorMessage } from '../error-message';
type VerificationState = 'loading' | 'success' | 'error' | 'missing-token';
@Component({
selector: 'app-verify-email',
imports: [
RouterLink,
MatButtonModule,
MatCardModule,
MatIconModule,
MatProgressSpinnerModule,
],
templateUrl: './verify-email.component.html',
styleUrl: '../auth-page.scss',
})
export class VerifyEmailComponent implements OnInit {
private readonly auth = inject(AuthService);
private readonly route = inject(ActivatedRoute);
protected readonly state = signal<VerificationState>('loading');
protected readonly message = signal('E-Mail wird bestaetigt.');
protected readonly email = signal<string | null>(null);
ngOnInit(): void {
const token = this.route.snapshot.queryParamMap.get('token');
if (!token) {
this.state.set('missing-token');
this.message.set('Der Verifikationslink enthaelt keinen Token.');
return;
}
this.auth.verifyEmail(token).subscribe({
next: (response) => {
this.email.set(response.user.email);
this.message.set(response.message);
this.state.set('success');
},
error: (error: unknown) => {
this.message.set(getAuthErrorMessage(error));
this.state.set('error');
},
});
}
}

View File

@@ -0,0 +1,149 @@
<section class="list-detail-page">
<header class="detail-header">
<button mat-icon-button type="button" aria-label="Zurueck" (click)="backToLists()">
<mat-icon aria-hidden="true">arrow_back</mat-icon>
</button>
<div>
<h1>{{ list()?.name || (isCreateMode() ? 'Neue Liste' : 'Liste') }}</h1>
@if (isCreateMode()) {
<p>Liste anlegen</p>
} @else if (list()) {
<p>{{ checkedCount(list()!) }} / {{ list()!.items.length }} erledigt</p>
}
</div>
</header>
@if (loading()) {
<mat-card class="state-card" appearance="outlined">
<mat-card-content>
<mat-progress-spinner mode="indeterminate" diameter="40" />
<h2>Liste wird geladen</h2>
</mat-card-content>
</mat-card>
} @else if (errorMessage()) {
<mat-card class="state-card error-state" appearance="outlined">
<mat-card-content>
<mat-icon aria-hidden="true">error</mat-icon>
<h2>Liste konnte nicht geladen werden</h2>
<p>{{ errorMessage() }}</p>
<button mat-stroked-button type="button" (click)="loadList()">
<mat-icon aria-hidden="true">refresh</mat-icon>
Erneut laden
</button>
</mat-card-content>
</mat-card>
} @else {
<mat-card class="editor-card" appearance="outlined">
<mat-card-header>
<mat-card-title>Details</mat-card-title>
</mat-card-header>
<mat-card-content>
<form [formGroup]="listForm" class="list-form" (ngSubmit)="saveList()">
<mat-form-field appearance="outline">
<mat-label>Titel</mat-label>
<input matInput formControlName="name" autocomplete="off" />
@if (listForm.controls.name.hasError('required')) {
<mat-error>Titel ist erforderlich.</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Beschreibung</mat-label>
<textarea matInput formControlName="description" rows="4"></textarea>
</mat-form-field>
<button mat-flat-button type="submit" [disabled]="saving()">
@if (saving()) {
<mat-progress-spinner mode="indeterminate" diameter="18" />
} @else {
<mat-icon aria-hidden="true">save</mat-icon>
}
{{ isCreateMode() ? 'Liste anlegen' : 'Speichern' }}
</button>
</form>
</mat-card-content>
</mat-card>
<mat-card class="items-card" appearance="outlined">
<mat-card-header>
<mat-card-title>Items</mat-card-title>
<mat-card-subtitle>
@if (canEditItems()) {
{{ list()?.items?.length || 0 }} Eintraege
} @else {
Nach dem Speichern verfuegbar
}
</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
<form [formGroup]="itemForm" class="item-form" (ngSubmit)="addItem()">
<mat-form-field appearance="outline">
<mat-label>Neues Item</mat-label>
<input matInput formControlName="title" autocomplete="off" [disabled]="!canEditItems()" />
@if (itemForm.controls.title.hasError('required')) {
<mat-error>Item-Titel ist erforderlich.</mat-error>
}
</mat-form-field>
<mat-checkbox formControlName="required">Pflicht</mat-checkbox>
<button mat-flat-button type="submit" [disabled]="addingItem() || !canEditItems()">
@if (addingItem()) {
<mat-progress-spinner mode="indeterminate" diameter="18" />
} @else {
<mat-icon aria-hidden="true">add</mat-icon>
}
Hinzufuegen
</button>
</form>
@if (!canEditItems()) {
<div class="inline-empty">
<mat-icon aria-hidden="true">save</mat-icon>
<span>Speichere die Liste, bevor du Items hinzufuegst.</span>
</div>
} @else if (list()?.items?.length) {
<ul class="check-items">
@for (item of list()!.items; track item.id) {
<li [class.checked]="item.checked">
<mat-checkbox
[checked]="item.checked"
[disabled]="updatingItemId() === item.id"
(change)="toggleItem(item, $event.checked)"
>
<span class="item-title">{{ item.title }}</span>
</mat-checkbox>
@if (updatingItemId() === item.id) {
<mat-progress-spinner mode="indeterminate" diameter="18" />
}
@if (item.checked && item.checkedAt && item.checkedByName) {
<div class="check-meta">
<mat-icon aria-hidden="true">verified</mat-icon>
<span>
Abgehakt von {{ item.checkedByName }} am
{{ item.checkedAt | date: 'dd.MM.yyyy, HH:mm' }}
</span>
</div>
}
</li>
}
</ul>
} @else {
<div class="inline-empty">
<mat-icon aria-hidden="true">playlist_add</mat-icon>
<span>Noch keine Items.</span>
</div>
}
</mat-card-content>
</mat-card>
<a mat-button routerLink="/lists" class="secondary-back">
<mat-icon aria-hidden="true">arrow_back</mat-icon>
Zur Listenuebersicht
</a>
}
</section>

View File

@@ -0,0 +1,147 @@
.list-detail-page {
display: grid;
gap: 1rem;
width: min(100%, 760px);
margin: 0 auto;
padding: 1rem;
}
.detail-header {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 0.75rem;
}
.detail-header h1 {
overflow: hidden;
margin: 0;
font-size: 1.45rem;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail-header p {
margin: 0.2rem 0 0;
color: var(--mat-sys-on-surface-variant);
}
.state-card,
.editor-card,
.items-card {
border-radius: 8px;
background: color-mix(in srgb, var(--mat-sys-surface-container-low) 94%, white);
}
.list-form,
.item-form {
display: grid;
gap: 0.75rem;
padding-top: 0.75rem;
}
.list-form mat-form-field,
.item-form mat-form-field {
width: 100%;
}
.list-form button[type='submit'],
.item-form button[type='submit'] {
min-height: 48px;
}
.list-form mat-progress-spinner,
.item-form mat-progress-spinner {
display: inline-flex;
margin-right: 0.5rem;
}
.state-card mat-card-content {
display: grid;
justify-items: center;
gap: 0.65rem;
padding: 2rem 1rem;
text-align: center;
}
.state-card mat-icon {
width: 48px;
height: 48px;
color: var(--mat-sys-primary);
font-size: 48px;
}
.error-state mat-icon {
color: var(--mat-sys-error);
}
.check-items {
display: grid;
gap: 0.6rem;
margin: 1rem 0 0;
padding: 0;
list-style: none;
}
.check-items li {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.35rem 0.5rem;
padding: 0.5rem;
border: 1px solid var(--mat-sys-outline-variant);
border-radius: 8px;
background: var(--mat-sys-surface);
}
.check-items li.checked {
background: color-mix(in srgb, var(--mat-sys-primary) 7%, var(--mat-sys-surface));
}
.item-title {
overflow-wrap: anywhere;
}
.check-meta {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 0.4rem;
color: var(--mat-sys-on-surface-variant);
font-size: 0.82rem;
}
.check-meta mat-icon {
width: 18px;
height: 18px;
color: var(--mat-sys-primary);
font-size: 18px;
}
.inline-empty {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 1rem;
color: var(--mat-sys-on-surface-variant);
}
.secondary-back {
justify-self: start;
}
@media (min-width: 701px) {
.list-detail-page {
gap: 1.25rem;
padding: 2rem;
}
.detail-header h1 {
font-size: 2rem;
}
.item-form {
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
}
}

View File

@@ -0,0 +1,210 @@
import { DatePipe } from '@angular/common';
import { Component, OnInit, computed, inject, signal } from '@angular/core';
import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { finalize } from 'rxjs';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { getAuthErrorMessage } from '../../auth/error-message';
import { UserList, UserListItem } from '../lists.models';
import { ListsService } from '../lists.service';
@Component({
selector: 'app-list-detail',
imports: [
DatePipe,
ReactiveFormsModule,
RouterLink,
MatButtonModule,
MatCardModule,
MatCheckboxModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatProgressSpinnerModule,
MatSnackBarModule,
],
templateUrl: './list-detail.component.html',
styleUrl: './list-detail.component.scss',
})
export class ListDetailComponent implements OnInit {
private readonly formBuilder = inject(NonNullableFormBuilder);
private readonly listsService = inject(ListsService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly snackBar = inject(MatSnackBar);
protected readonly list = signal<UserList | null>(null);
protected readonly isCreateMode = signal(false);
protected readonly loading = signal(true);
protected readonly saving = signal(false);
protected readonly addingItem = signal(false);
protected readonly errorMessage = signal<string | null>(null);
protected readonly updatingItemId = signal<string | null>(null);
protected readonly canEditItems = computed(() => Boolean(this.list()?.id));
protected readonly listForm = this.formBuilder.group({
name: ['', [Validators.required]],
description: [''],
});
protected readonly itemForm = this.formBuilder.group({
title: ['', [Validators.required]],
required: [true],
});
ngOnInit(): void {
this.isCreateMode.set(this.listId() === null);
if (this.isCreateMode()) {
this.loading.set(false);
this.listForm.reset({ name: '', description: '' });
return;
}
this.loadList();
}
protected loadList(): void {
const listId = this.listId();
if (!listId) {
this.errorMessage.set('Liste wurde nicht gefunden.');
this.loading.set(false);
return;
}
this.loading.set(true);
this.errorMessage.set(null);
this.listsService.getList(listId).subscribe({
next: (list) => {
this.setList(list);
this.loading.set(false);
},
error: (error: unknown) => {
this.errorMessage.set(getAuthErrorMessage(error));
this.loading.set(false);
},
});
}
protected saveList(): void {
const listId = this.listId();
if (this.listForm.invalid) {
this.listForm.markAllAsTouched();
return;
}
const formValue = this.listForm.getRawValue();
const payload = {
name: formValue.name.trim(),
description: formValue.description.trim() || undefined,
};
const saveRequest =
this.isCreateMode() || !listId
? this.listsService.createList(payload)
: this.listsService.updateList(listId, payload);
this.saving.set(true);
saveRequest.pipe(finalize(() => this.saving.set(false))).subscribe({
next: (list) => {
this.setList(list);
if (this.isCreateMode()) {
this.isCreateMode.set(false);
void this.router.navigate(['/lists', list.id], { replaceUrl: true });
}
this.snackBar.open('Liste gespeichert.', 'OK', { duration: 2500 });
},
error: (error: unknown) => {
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
},
});
}
protected addItem(): void {
const listId = this.listId();
if (!listId || !this.canEditItems() || this.itemForm.invalid) {
this.itemForm.markAllAsTouched();
return;
}
const formValue = this.itemForm.getRawValue();
this.addingItem.set(true);
this.listsService
.addItem(listId, {
title: formValue.title.trim(),
required: formValue.required,
})
.pipe(finalize(() => this.addingItem.set(false)))
.subscribe({
next: (list) => {
this.setList(list);
this.itemForm.reset({ title: '', required: true });
},
error: (error: unknown) => {
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
},
});
}
protected toggleItem(item: UserListItem, checked: boolean): void {
const listId = this.listId();
const currentList = this.list();
if (!listId || !currentList || this.updatingItemId()) {
return;
}
this.updatingItemId.set(item.id);
this.list.set({
...currentList,
items: currentList.items.map((existingItem) =>
existingItem.id === item.id ? { ...existingItem, checked } : existingItem,
),
});
this.listsService
.updateItem(listId, item.id, { checked })
.pipe(finalize(() => this.updatingItemId.set(null)))
.subscribe({
next: (list) => {
this.setList(list);
},
error: (error: unknown) => {
this.list.set(currentList);
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
},
});
}
protected checkedCount(list: UserList): number {
return list.items.filter((item) => item.checked).length;
}
protected async backToLists(): Promise<void> {
await this.router.navigateByUrl('/lists');
}
private setList(list: UserList): void {
this.list.set(list);
this.listForm.reset({
name: list.name,
description: list.description ?? '',
});
}
private listId(): string | null {
return this.route.snapshot.paramMap.get('listId');
}
}

View File

@@ -0,0 +1,90 @@
<section class="workspace-page">
<header class="page-header">
<div>
<h1>Listen</h1>
<p>Deine persoenlichen Listify-Listen.</p>
</div>
<a mat-flat-button routerLink="/lists/new">
<mat-icon aria-hidden="true">add</mat-icon>
Neue Liste
</a>
</header>
@if (loading()) {
<mat-card class="state-card" appearance="outlined">
<mat-card-content>
<mat-progress-spinner mode="indeterminate" diameter="40" />
<h2>Listen werden geladen</h2>
</mat-card-content>
</mat-card>
} @else if (errorMessage()) {
<mat-card class="state-card error-state" appearance="outlined">
<mat-card-content>
<mat-icon aria-hidden="true">error</mat-icon>
<h2>Listen konnten nicht geladen werden</h2>
<p>{{ errorMessage() }}</p>
<button mat-stroked-button type="button" (click)="loadLists()">
<mat-icon aria-hidden="true">refresh</mat-icon>
Erneut laden
</button>
</mat-card-content>
</mat-card>
} @else if (hasLists()) {
<div class="template-grid">
@for (list of lists(); 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-header>
<mat-card-content>
@if (list.description) {
<p class="template-description">{{ list.description }}</p>
}
<div class="template-meta">
<span>
<mat-icon aria-hidden="true">done_all</mat-icon>
{{ checkedCount(list) }} / {{ list.items.length }} erledigt
</span>
<span>
<mat-icon aria-hidden="true">schedule</mat-icon>
{{ list.updatedAt | date: 'dd.MM.yyyy' }}
</span>
</div>
@if (list.items.length > 0) {
<ul class="template-items">
@for (item of list.items.slice(0, 4); track item.id) {
<li>
<mat-icon aria-hidden="true">
{{ item.checked ? 'check_circle' : 'radio_button_unchecked' }}
</mat-icon>
<span>{{ item.title }}</span>
</li>
}
</ul>
}
</mat-card-content>
<mat-card-actions align="end">
<a mat-button [routerLink]="['/lists', list.id]">
<mat-icon aria-hidden="true">open_in_new</mat-icon>
Oeffnen
</a>
</mat-card-actions>
</mat-card>
}
</div>
} @else {
<mat-card class="state-card" appearance="outlined">
<mat-card-content>
<mat-icon aria-hidden="true">format_list_bulleted</mat-icon>
<h2>Noch keine Listen</h2>
<p>Erstelle eine Liste aus einem Template oder lege eine neue Liste an.</p>
</mat-card-content>
</mat-card>
}
</section>

View File

@@ -0,0 +1,68 @@
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 { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { getAuthErrorMessage } from '../auth/error-message';
import { ListTemplateKind } from '../templates/templates.models';
import { UserList } from './lists.models';
import { ListsService } from './lists.service';
@Component({
selector: 'app-lists',
imports: [
DatePipe,
RouterLink,
MatButtonModule,
MatCardModule,
MatIconModule,
MatProgressSpinnerModule,
],
templateUrl: './lists.component.html',
styleUrl: '../workspace-page.scss',
})
export class ListsComponent implements OnInit {
private readonly listsService = inject(ListsService);
protected readonly lists = signal<UserList[]>([]);
protected readonly loading = signal(true);
protected readonly errorMessage = signal<string | null>(null);
protected readonly hasLists = computed(() => this.lists().length > 0);
ngOnInit(): void {
this.loadLists();
}
protected loadLists(): void {
this.loading.set(true);
this.errorMessage.set(null);
this.listsService.listLists().subscribe({
next: (lists) => {
this.lists.set(lists);
this.loading.set(false);
},
error: (error: unknown) => {
this.errorMessage.set(getAuthErrorMessage(error));
this.loading.set(false);
},
});
}
protected kindLabel(kind: ListTemplateKind): string {
const labels: Record<ListTemplateKind, string> = {
packing: 'Packliste',
shopping: 'Einkauf',
todo: 'Todo',
custom: 'Eigene Liste',
};
return labels[kind];
}
protected checkedCount(list: UserList): number {
return list.items.filter((item) => item.checked).length;
}
}

View File

@@ -0,0 +1,56 @@
import { ListTemplateKind } from '../templates/templates.models';
export interface UserListItem {
id: string;
sourceTemplateItemId?: string;
title: string;
notes?: string;
quantity?: number;
required: boolean;
checked: boolean;
checkedAt?: string;
checkedByUserId?: string;
checkedByName?: string;
position: number;
createdAt: string;
updatedAt: string;
}
export interface UserList {
id: string;
ownerId: string;
sourceTemplateId?: string;
name: string;
description?: string;
kind: ListTemplateKind;
items: UserListItem[];
createdAt: string;
updatedAt: string;
}
export interface CreateListRequest {
name: string;
description?: string;
kind?: ListTemplateKind;
}
export interface UpdateListRequest {
name?: string;
description?: string;
kind?: ListTemplateKind;
}
export interface AddListItemRequest {
title: string;
notes?: string;
quantity?: number;
required?: boolean;
}
export interface UpdateListItemRequest {
title?: string;
notes?: string;
quantity?: number;
required?: boolean;
checked?: boolean;
}

View File

@@ -0,0 +1,47 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import {
AddListItemRequest,
CreateListRequest,
UpdateListItemRequest,
UpdateListRequest,
UserList,
} from './lists.models';
@Injectable({ providedIn: 'root' })
export class ListsService {
private readonly http = inject(HttpClient);
private readonly apiUrl = '/api/lists';
listLists(): Observable<UserList[]> {
return this.http.get<UserList[]>(this.apiUrl);
}
getList(listId: string): Observable<UserList> {
return this.http.get<UserList>(`${this.apiUrl}/${listId}`);
}
createList(data: CreateListRequest): Observable<UserList> {
return this.http.post<UserList>(this.apiUrl, data);
}
updateList(listId: string, data: UpdateListRequest): Observable<UserList> {
return this.http.patch<UserList>(`${this.apiUrl}/${listId}`, data);
}
addItem(listId: string, data: AddListItemRequest): Observable<UserList> {
return this.http.post<UserList>(`${this.apiUrl}/${listId}/items`, data);
}
updateItem(
listId: string,
itemId: string,
data: UpdateListItemRequest,
): Observable<UserList> {
return this.http.patch<UserList>(
`${this.apiUrl}/${listId}/items/${itemId}`,
data,
);
}
}

View File

@@ -0,0 +1,20 @@
<div class="delete-dialog-icon">
<mat-icon aria-hidden="true">delete_forever</mat-icon>
</div>
<h2 mat-dialog-title>Template loeschen?</h2>
<mat-dialog-content>
<p>
<strong>{{ data.templateName }}</strong> wird dauerhaft geloescht. Bereits daraus erstellte
Listen bleiben erhalten.
</p>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button type="button" mat-dialog-close>Abbrechen</button>
<button mat-flat-button type="button" color="warn" [mat-dialog-close]="true">
<mat-icon aria-hidden="true">delete</mat-icon>
Loeschen
</button>
</mat-dialog-actions>

View File

@@ -0,0 +1,36 @@
:host {
display: block;
padding-top: 1rem;
}
.delete-dialog-icon {
display: grid;
place-items: center;
width: 56px;
height: 56px;
margin: 0 auto 0.25rem;
border-radius: 50%;
background: color-mix(in srgb, var(--mat-sys-error) 12%, transparent);
color: var(--mat-sys-error);
}
.delete-dialog-icon mat-icon {
width: 32px;
height: 32px;
font-size: 32px;
}
h2[mat-dialog-title] {
padding-top: 0;
text-align: center;
}
mat-dialog-content p {
margin: 0;
color: var(--mat-sys-on-surface-variant);
text-align: center;
}
mat-dialog-actions {
padding-bottom: 1rem;
}

View File

@@ -0,0 +1,18 @@
import { Component, inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
export interface ConfirmDeleteDialogData {
templateName: string;
}
@Component({
selector: 'app-confirm-delete-dialog',
imports: [MatButtonModule, MatDialogModule, MatIconModule],
templateUrl: './confirm-delete-dialog.component.html',
styleUrl: './confirm-delete-dialog.component.scss',
})
export class ConfirmDeleteDialogComponent {
protected readonly data = inject<ConfirmDeleteDialogData>(MAT_DIALOG_DATA);
}

View File

@@ -0,0 +1,177 @@
<section class="template-detail-page">
<header class="detail-header">
<button mat-icon-button type="button" aria-label="Zurueck" (click)="backToTemplates()">
<mat-icon aria-hidden="true">arrow_back</mat-icon>
</button>
<div>
<h1>{{ template()?.name || (isCreateMode() ? 'Neues Template' : 'Template') }}</h1>
<p>{{ isCreateMode() ? 'Vorlage anlegen' : 'Vorlage bearbeiten' }}</p>
</div>
@if (canEditItems()) {
<div class="detail-actions">
<button mat-stroked-button type="button" [disabled]="copyingTemplate()" (click)="copyTemplateToList()">
@if (copyingTemplate()) {
<mat-progress-spinner mode="indeterminate" diameter="18" />
} @else {
<mat-icon aria-hidden="true">content_copy</mat-icon>
}
Als Liste
</button>
<button mat-icon-button type="button" aria-label="Template loeschen" [disabled]="deletingTemplate()" (click)="deleteTemplate()">
@if (deletingTemplate()) {
<mat-progress-spinner mode="indeterminate" diameter="18" />
} @else {
<mat-icon aria-hidden="true">delete</mat-icon>
}
</button>
</div>
}
</header>
@if (loading()) {
<mat-card class="state-card" appearance="outlined">
<mat-card-content>
<mat-progress-spinner mode="indeterminate" diameter="40" />
<h2>Template wird geladen</h2>
</mat-card-content>
</mat-card>
} @else if (errorMessage()) {
<mat-card class="state-card error-state" appearance="outlined">
<mat-card-content>
<mat-icon aria-hidden="true">error</mat-icon>
<h2>Template konnte nicht geladen werden</h2>
<p>{{ errorMessage() }}</p>
<button mat-stroked-button type="button" (click)="loadTemplate()">
<mat-icon aria-hidden="true">refresh</mat-icon>
Erneut laden
</button>
</mat-card-content>
</mat-card>
} @else {
<mat-card class="editor-card" appearance="outlined">
<mat-card-header>
<mat-card-title>Details</mat-card-title>
</mat-card-header>
<mat-card-content>
<form [formGroup]="templateForm" class="template-form" (ngSubmit)="saveTemplate()">
<mat-form-field appearance="outline">
<mat-label>Titel</mat-label>
<input matInput formControlName="name" autocomplete="off" />
@if (templateForm.controls.name.hasError('required')) {
<mat-error>Titel ist erforderlich.</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Beschreibung</mat-label>
<textarea matInput formControlName="description" rows="4"></textarea>
</mat-form-field>
<button mat-flat-button type="submit" [disabled]="saving()">
@if (saving()) {
<mat-progress-spinner mode="indeterminate" diameter="18" />
} @else {
<mat-icon aria-hidden="true">save</mat-icon>
}
{{ isCreateMode() ? 'Template anlegen' : 'Speichern' }}
</button>
</form>
</mat-card-content>
</mat-card>
<mat-card class="editor-card" appearance="outlined">
<mat-card-header>
<mat-card-title>Items</mat-card-title>
<mat-card-subtitle>
@if (canEditItems()) {
{{ template()?.items?.length || 0 }} Eintraege
@if (reordering()) {
- Reihenfolge wird gespeichert
}
} @else {
Nach dem Speichern verfuegbar
}
</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
<form [formGroup]="itemForm" class="item-form" (ngSubmit)="addItem()">
<mat-form-field appearance="outline">
<mat-label>Neues Item</mat-label>
<input matInput formControlName="title" autocomplete="off" [disabled]="!canEditItems()" />
@if (itemForm.controls.title.hasError('required')) {
<mat-error>Item-Titel ist erforderlich.</mat-error>
}
</mat-form-field>
<mat-checkbox formControlName="required">Pflicht</mat-checkbox>
<button mat-flat-button type="submit" [disabled]="addingItem() || !canEditItems()">
@if (addingItem()) {
<mat-progress-spinner mode="indeterminate" diameter="18" />
} @else {
<mat-icon aria-hidden="true">add</mat-icon>
}
Hinzufuegen
</button>
</form>
@if (!canEditItems()) {
<div class="inline-empty">
<mat-icon aria-hidden="true">save</mat-icon>
<span>Speichere das Template, bevor du Items hinzufuegst.</span>
</div>
} @else if (template()?.items?.length) {
<ul
class="detail-items"
cdkDropList
[cdkDropListData]="template()?.items || []"
(cdkDropListDropped)="reorderItems($event)"
>
@for (item of template()?.items; track item.id) {
<li cdkDrag [cdkDragDisabled]="reordering() || deletingItemId() === item.id">
<button
mat-icon-button
type="button"
class="drag-handle"
cdkDragHandle
aria-label="Item verschieben"
>
<mat-icon aria-hidden="true">drag_handle</mat-icon>
</button>
<mat-icon class="item-state-icon" aria-hidden="true">
{{ item.required ? 'radio_button_unchecked' : 'remove_circle_outline' }}
</mat-icon>
<span>{{ item.title }}</span>
<button
mat-icon-button
type="button"
[attr.aria-label]="item.title + ' entfernen'"
[disabled]="deletingItemId() === item.id"
(click)="deleteItem(item.id)"
>
@if (deletingItemId() === item.id) {
<mat-progress-spinner mode="indeterminate" diameter="18" />
} @else {
<mat-icon aria-hidden="true">delete</mat-icon>
}
</button>
</li>
}
</ul>
} @else {
<div class="inline-empty">
<mat-icon aria-hidden="true">playlist_add</mat-icon>
<span>Noch keine Items.</span>
</div>
}
</mat-card-content>
</mat-card>
<a mat-button routerLink="/templates" class="secondary-back">
<mat-icon aria-hidden="true">arrow_back</mat-icon>
Zur Template-Uebersicht
</a>
}
</section>

View File

@@ -0,0 +1,176 @@
.template-detail-page {
display: grid;
gap: 1rem;
width: min(100%, 760px);
margin: 0 auto;
padding: 1rem;
}
.detail-header {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 0.75rem;
}
.detail-header h1 {
overflow: hidden;
margin: 0;
font-size: 1.45rem;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail-header p {
margin: 0.2rem 0 0;
color: var(--mat-sys-on-surface-variant);
}
.detail-actions {
grid-column: 1 / -1;
display: flex;
justify-content: flex-end;
gap: 0.35rem;
}
.detail-actions mat-progress-spinner {
display: inline-flex;
margin-right: 0.5rem;
}
.editor-card,
.state-card {
border-radius: 8px;
background: color-mix(in srgb, var(--mat-sys-surface-container-low) 94%, white);
}
.template-form,
.item-form {
display: grid;
gap: 0.75rem;
padding-top: 0.75rem;
}
.template-form mat-form-field,
.item-form mat-form-field {
width: 100%;
}
.template-form button[type='submit'],
.item-form button[type='submit'] {
min-height: 48px;
}
.template-form mat-progress-spinner,
.item-form mat-progress-spinner {
display: inline-flex;
margin-right: 0.5rem;
}
.state-card mat-card-content {
display: grid;
justify-items: center;
gap: 0.65rem;
padding: 2rem 1rem;
text-align: center;
}
.state-card mat-icon {
width: 48px;
height: 48px;
color: var(--mat-sys-primary);
font-size: 48px;
}
.error-state mat-icon {
color: var(--mat-sys-error);
}
.detail-items {
display: grid;
gap: 0.5rem;
margin: 1rem 0 0;
padding: 0;
list-style: none;
}
.detail-items li {
display: grid;
grid-template-columns: auto auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.35rem;
min-height: 52px;
padding: 0.35rem;
border: 1px solid var(--mat-sys-outline-variant);
border-radius: 8px;
background: var(--mat-sys-surface);
}
.detail-items li.cdk-drag-preview {
box-sizing: border-box;
box-shadow: var(--mat-sys-level3);
}
.detail-items li.cdk-drag-placeholder {
opacity: 0.3;
}
.detail-items.cdk-drop-list-dragging li:not(.cdk-drag-placeholder) {
transition: transform 180ms cubic-bezier(0, 0, 0.2, 1);
}
.drag-handle {
color: var(--mat-sys-on-surface-variant);
cursor: grab;
}
.drag-handle:active {
cursor: grabbing;
}
.item-state-icon {
color: var(--mat-sys-primary);
}
.detail-items span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail-items button mat-progress-spinner {
display: inline-flex;
}
.inline-empty {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 1rem;
color: var(--mat-sys-on-surface-variant);
}
.secondary-back {
justify-self: start;
}
@media (min-width: 701px) {
.template-detail-page {
gap: 1.25rem;
padding: 2rem;
}
.detail-header h1 {
font-size: 2rem;
}
.detail-actions {
grid-column: auto;
}
.item-form {
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
}
}

View File

@@ -0,0 +1,321 @@
import { Component, OnInit, computed, inject, signal } from '@angular/core';
import { CdkDragDrop, DragDropModule, moveItemInArray } from '@angular/cdk/drag-drop';
import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { finalize } from 'rxjs';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { getAuthErrorMessage } from '../../auth/error-message';
import { ConfirmDeleteDialogComponent } from '../confirm-delete-dialog/confirm-delete-dialog.component';
import { ListTemplate } from '../templates.models';
import { TemplatesService } from '../templates.service';
@Component({
selector: 'app-template-detail',
imports: [
ReactiveFormsModule,
DragDropModule,
RouterLink,
MatButtonModule,
MatCardModule,
MatCheckboxModule,
MatDialogModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatProgressSpinnerModule,
MatSnackBarModule,
],
templateUrl: './template-detail.component.html',
styleUrl: './template-detail.component.scss',
})
export class TemplateDetailComponent implements OnInit {
private readonly formBuilder = inject(NonNullableFormBuilder);
private readonly dialog = inject(MatDialog);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly snackBar = inject(MatSnackBar);
private readonly templatesService = inject(TemplatesService);
protected readonly template = signal<ListTemplate | null>(null);
protected readonly isCreateMode = signal(false);
protected readonly loading = signal(true);
protected readonly saving = signal(false);
protected readonly addingItem = signal(false);
protected readonly deletingItemId = signal<string | null>(null);
protected readonly deletingTemplate = signal(false);
protected readonly copyingTemplate = signal(false);
protected readonly reordering = signal(false);
protected readonly errorMessage = signal<string | null>(null);
protected readonly templateForm = this.formBuilder.group({
name: ['', [Validators.required]],
description: [''],
});
protected readonly itemForm = this.formBuilder.group({
title: ['', [Validators.required]],
required: [true],
});
protected readonly canEditItems = computed(() => Boolean(this.template()?.id));
ngOnInit(): void {
this.isCreateMode.set(this.templateId() === null);
if (this.isCreateMode()) {
this.loading.set(false);
this.templateForm.reset({ name: '', description: '' });
return;
}
this.loadTemplate();
}
protected loadTemplate(): void {
const templateId = this.templateId();
if (!templateId) {
this.errorMessage.set('Template wurde nicht gefunden.');
this.loading.set(false);
return;
}
this.loading.set(true);
this.errorMessage.set(null);
this.templatesService.getTemplate(templateId).subscribe({
next: (template) => {
this.setTemplate(template);
this.loading.set(false);
},
error: (error: unknown) => {
this.errorMessage.set(getAuthErrorMessage(error));
this.loading.set(false);
},
});
}
protected saveTemplate(): void {
const templateId = this.templateId();
if (this.templateForm.invalid) {
this.templateForm.markAllAsTouched();
return;
}
const formValue = this.templateForm.getRawValue();
const payload = {
name: formValue.name.trim(),
description: formValue.description.trim() || undefined,
};
this.saving.set(true);
const saveRequest =
this.isCreateMode() || !templateId
? this.templatesService.createTemplate(payload)
: this.templatesService.updateTemplate(templateId, payload);
saveRequest
.pipe(finalize(() => this.saving.set(false)))
.subscribe({
next: (template) => {
this.setTemplate(template);
if (this.isCreateMode()) {
this.isCreateMode.set(false);
void this.router.navigate(['/templates', template.id], {
replaceUrl: true,
});
}
this.snackBar.open('Template gespeichert.', 'OK', { duration: 2500 });
},
error: (error: unknown) => {
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
},
});
}
protected addItem(): void {
const templateId = this.templateId();
if (!templateId || !this.canEditItems() || this.itemForm.invalid) {
this.itemForm.markAllAsTouched();
return;
}
const formValue = this.itemForm.getRawValue();
this.addingItem.set(true);
this.templatesService
.addItem(templateId, {
title: formValue.title.trim(),
required: formValue.required,
})
.pipe(finalize(() => this.addingItem.set(false)))
.subscribe({
next: (template) => {
this.setTemplate(template);
this.itemForm.reset({ title: '', required: true });
},
error: (error: unknown) => {
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
},
});
}
protected deleteItem(itemId: string): void {
const templateId = this.templateId();
if (!templateId || this.deletingItemId()) {
return;
}
this.deletingItemId.set(itemId);
this.templatesService
.deleteItem(templateId, itemId)
.pipe(finalize(() => this.deletingItemId.set(null)))
.subscribe({
next: (template) => {
this.setTemplate(template);
},
error: (error: unknown) => {
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
},
});
}
protected reorderItems(event: CdkDragDrop<NonNullable<ListTemplate['items']>>): void {
const templateId = this.templateId();
const currentTemplate = this.template();
if (
!templateId ||
!currentTemplate ||
this.reordering() ||
event.previousIndex === event.currentIndex
) {
return;
}
const previousItems = [...currentTemplate.items];
const reorderedItems = [...currentTemplate.items];
moveItemInArray(reorderedItems, event.previousIndex, event.currentIndex);
const optimisticTemplate = {
...currentTemplate,
items: reorderedItems.map((item, index) => ({ ...item, position: index })),
};
this.template.set(optimisticTemplate);
this.reordering.set(true);
this.templatesService
.reorderItems(templateId, {
itemIds: optimisticTemplate.items.map((item) => item.id),
})
.pipe(finalize(() => this.reordering.set(false)))
.subscribe({
next: (template) => {
this.setTemplate(template);
},
error: (error: unknown) => {
this.template.set({ ...currentTemplate, items: previousItems });
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
},
});
}
protected copyTemplateToList(): void {
const templateId = this.templateId();
if (!templateId || !this.canEditItems() || this.copyingTemplate()) {
return;
}
this.copyingTemplate.set(true);
this.templatesService
.createListFromTemplate(templateId)
.pipe(finalize(() => this.copyingTemplate.set(false)))
.subscribe({
next: () => {
this.snackBar.open('Liste aus Template erstellt.', 'OK', {
duration: 3000,
});
void this.router.navigateByUrl('/lists');
},
error: (error: unknown) => {
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
},
});
}
protected deleteTemplate(): void {
const templateId = this.templateId();
const template = this.template();
if (
!templateId ||
!template ||
this.deletingTemplate()
) {
return;
}
this.dialog
.open<ConfirmDeleteDialogComponent, { templateName: string }, boolean>(
ConfirmDeleteDialogComponent,
{
data: { templateName: template.name },
maxWidth: '420px',
width: 'calc(100vw - 32px)',
},
)
.afterClosed()
.subscribe((confirmed) => {
if (!confirmed) {
return;
}
this.deletingTemplate.set(true);
this.templatesService
.deleteTemplate(templateId)
.pipe(finalize(() => this.deletingTemplate.set(false)))
.subscribe({
next: () => {
this.snackBar.open('Template geloescht.', 'OK', { duration: 3000 });
void this.router.navigateByUrl('/templates');
},
error: (error: unknown) => {
this.snackBar.open(getAuthErrorMessage(error), 'OK', {
duration: 5000,
});
},
});
});
}
protected async backToTemplates(): Promise<void> {
await this.router.navigateByUrl('/templates');
}
private setTemplate(template: ListTemplate): void {
this.template.set(template);
this.templateForm.reset({
name: template.name,
description: template.description ?? '',
});
}
private templateId(): string | null {
return this.route.snapshot.paramMap.get('templateId');
}
}

View File

@@ -0,0 +1,116 @@
<section class="workspace-page">
<header class="page-header">
<div>
<h1>Templates</h1>
<p>Vorlagen fuer wiederkehrende Listen.</p>
</div>
<a mat-flat-button routerLink="/templates/new">
<mat-icon aria-hidden="true">add</mat-icon>
Neues Template
</a>
</header>
@if (loading()) {
<mat-card class="state-card" appearance="outlined">
<mat-card-content>
<mat-progress-spinner mode="indeterminate" diameter="40" />
<h2>Templates werden geladen</h2>
</mat-card-content>
</mat-card>
} @else if (errorMessage()) {
<mat-card class="state-card error-state" appearance="outlined">
<mat-card-content>
<mat-icon aria-hidden="true">error</mat-icon>
<h2>Templates konnten nicht geladen werden</h2>
<p>{{ errorMessage() }}</p>
<button mat-stroked-button type="button" (click)="loadTemplates()">
<mat-icon aria-hidden="true">refresh</mat-icon>
Erneut laden
</button>
</mat-card-content>
</mat-card>
} @else if (hasTemplates()) {
<div class="template-grid">
@for (template of templates(); track template.id) {
<mat-card class="template-card" appearance="outlined">
<mat-card-header>
<mat-card-title>{{ template.name }}</mat-card-title>
<mat-card-subtitle>{{ kindLabel(template.kind) }}</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
@if (template.description) {
<p class="template-description">{{ template.description }}</p>
}
<div class="template-meta">
<span>
<mat-icon aria-hidden="true">checklist</mat-icon>
{{ template.items.length }} Eintraege
</span>
<span>
<mat-icon aria-hidden="true">schedule</mat-icon>
{{ template.updatedAt | date: 'dd.MM.yyyy' }}
</span>
</div>
@if (template.items.length > 0) {
<ul class="template-items">
@for (item of template.items.slice(0, 4); track item.id) {
<li>
<mat-icon aria-hidden="true">
{{ item.required ? 'radio_button_unchecked' : 'remove_circle_outline' }}
</mat-icon>
<span>{{ item.title }}</span>
</li>
}
</ul>
}
</mat-card-content>
<mat-card-actions align="end">
<button
mat-button
type="button"
[disabled]="copyingTemplateId() === template.id"
(click)="copyTemplate(template)"
>
@if (copyingTemplateId() === template.id) {
<mat-progress-spinner mode="indeterminate" diameter="18" />
} @else {
<mat-icon aria-hidden="true">content_copy</mat-icon>
}
Als Liste
</button>
<a mat-button [routerLink]="['/templates', template.id]">
<mat-icon aria-hidden="true">edit</mat-icon>
Bearbeiten
</a>
<button
mat-icon-button
type="button"
[attr.aria-label]="template.name + ' loeschen'"
[disabled]="deletingTemplateId() === template.id"
(click)="deleteTemplate(template)"
>
@if (deletingTemplateId() === template.id) {
<mat-progress-spinner mode="indeterminate" diameter="18" />
} @else {
<mat-icon aria-hidden="true">delete</mat-icon>
}
</button>
</mat-card-actions>
</mat-card>
}
</div>
} @else {
<mat-card class="state-card" appearance="outlined">
<mat-card-content>
<mat-icon aria-hidden="true">dashboard_customize</mat-icon>
<h2>Noch keine Templates</h2>
<p>Erstelle dein erstes Template, um wiederkehrende Listen schneller anzulegen.</p>
</mat-card-content>
</mat-card>
}
</section>

View File

@@ -0,0 +1,142 @@
import { Component, OnInit, computed, inject, signal } from '@angular/core';
import { DatePipe } from '@angular/common';
import { Router, RouterLink } from '@angular/router';
import { finalize } from 'rxjs';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
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 { ConfirmDeleteDialogComponent } from './confirm-delete-dialog/confirm-delete-dialog.component';
import { ListTemplate, ListTemplateKind } from './templates.models';
import { TemplatesService } from './templates.service';
@Component({
selector: 'app-templates',
imports: [
DatePipe,
RouterLink,
MatButtonModule,
MatCardModule,
MatDialogModule,
MatIconModule,
MatProgressSpinnerModule,
MatSnackBarModule,
],
templateUrl: './templates.component.html',
styleUrl: '../workspace-page.scss',
})
export class TemplatesComponent implements OnInit {
private readonly router = inject(Router);
private readonly dialog = inject(MatDialog);
private readonly snackBar = inject(MatSnackBar);
private readonly templatesService = inject(TemplatesService);
protected readonly templates = signal<ListTemplate[]>([]);
protected readonly loading = signal(true);
protected readonly copyingTemplateId = signal<string | null>(null);
protected readonly deletingTemplateId = signal<string | null>(null);
protected readonly errorMessage = signal<string | null>(null);
protected readonly hasTemplates = computed(() => this.templates().length > 0);
ngOnInit(): void {
this.loadTemplates();
}
protected loadTemplates(): void {
this.loading.set(true);
this.errorMessage.set(null);
this.templatesService.listTemplates().subscribe({
next: (templates) => {
this.templates.set(templates);
this.loading.set(false);
},
error: (error: unknown) => {
this.errorMessage.set(getAuthErrorMessage(error));
this.loading.set(false);
},
});
}
protected kindLabel(kind: ListTemplateKind): string {
const labels: Record<ListTemplateKind, string> = {
packing: 'Packliste',
shopping: 'Einkauf',
todo: 'Todo',
custom: 'Eigene Vorlage',
};
return labels[kind];
}
protected copyTemplate(template: ListTemplate): void {
if (this.copyingTemplateId()) {
return;
}
this.copyingTemplateId.set(template.id);
this.templatesService
.createListFromTemplate(template.id)
.pipe(finalize(() => this.copyingTemplateId.set(null)))
.subscribe({
next: () => {
this.snackBar.open('Liste aus Template erstellt.', 'OK', {
duration: 3000,
});
void this.router.navigateByUrl('/lists');
},
error: (error: unknown) => {
this.snackBar.open(getAuthErrorMessage(error), 'OK', {
duration: 5000,
});
},
});
}
protected deleteTemplate(template: ListTemplate): void {
if (this.deletingTemplateId()) {
return;
}
this.dialog
.open<ConfirmDeleteDialogComponent, { templateName: string }, boolean>(
ConfirmDeleteDialogComponent,
{
data: { templateName: template.name },
maxWidth: '420px',
width: 'calc(100vw - 32px)',
},
)
.afterClosed()
.subscribe((confirmed) => {
if (!confirmed) {
return;
}
this.deletingTemplateId.set(template.id);
this.templatesService
.deleteTemplate(template.id)
.pipe(finalize(() => this.deletingTemplateId.set(null)))
.subscribe({
next: () => {
this.templates.update((templates) =>
templates.filter(
(existingTemplate) => existingTemplate.id !== template.id,
),
);
this.snackBar.open('Template geloescht.', 'OK', { duration: 3000 });
},
error: (error: unknown) => {
this.snackBar.open(getAuthErrorMessage(error), 'OK', {
duration: 5000,
});
},
});
});
}
}

View File

@@ -0,0 +1,76 @@
export type ListTemplateKind = 'packing' | 'shopping' | 'todo' | 'custom';
export interface ListTemplateItem {
id: string;
title: string;
notes?: string;
quantity?: number;
required: boolean;
position: number;
createdAt: string;
updatedAt: string;
}
export interface ListTemplate {
id: string;
ownerId: string;
name: string;
description?: string;
kind: ListTemplateKind;
items: ListTemplateItem[];
createdAt: string;
updatedAt: string;
}
export interface UserListItem {
id: string;
sourceTemplateItemId?: string;
title: string;
notes?: string;
quantity?: number;
required: boolean;
checked: boolean;
position: number;
createdAt: string;
updatedAt: string;
}
export interface UserList {
id: string;
ownerId: string;
sourceTemplateId?: string;
name: string;
description?: string;
kind: ListTemplateKind;
items: UserListItem[];
createdAt: string;
updatedAt: string;
}
export interface UpdateListTemplateRequest {
name?: string;
description?: string;
kind?: ListTemplateKind;
}
export interface CreateListTemplateRequest {
name: string;
description?: string;
kind?: ListTemplateKind;
}
export interface AddListTemplateItemRequest {
title: string;
notes?: string;
quantity?: number;
required?: boolean;
}
export interface ReorderListTemplateItemsRequest {
itemIds: string[];
}
export interface CreateListFromTemplateRequest {
name?: string;
description?: string;
}

View File

@@ -0,0 +1,71 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import {
AddListTemplateItemRequest,
CreateListFromTemplateRequest,
CreateListTemplateRequest,
ListTemplate,
ReorderListTemplateItemsRequest,
UpdateListTemplateRequest,
UserList,
} from './templates.models';
@Injectable({ providedIn: 'root' })
export class TemplatesService {
private readonly http = inject(HttpClient);
private readonly apiUrl = '/api/list-templates';
listTemplates(): Observable<ListTemplate[]> {
return this.http.get<ListTemplate[]>(this.apiUrl);
}
getTemplate(templateId: string): Observable<ListTemplate> {
return this.http.get<ListTemplate>(`${this.apiUrl}/${templateId}`);
}
createTemplate(data: CreateListTemplateRequest): Observable<ListTemplate> {
return this.http.post<ListTemplate>(this.apiUrl, data);
}
updateTemplate(
templateId: string,
data: UpdateListTemplateRequest,
): Observable<ListTemplate> {
return this.http.patch<ListTemplate>(`${this.apiUrl}/${templateId}`, data);
}
deleteTemplate(templateId: string): Observable<{ message: string }> {
return this.http.delete<{ message: string }>(`${this.apiUrl}/${templateId}`);
}
createListFromTemplate(
templateId: string,
data: CreateListFromTemplateRequest = {},
): Observable<UserList> {
return this.http.post<UserList>(`${this.apiUrl}/${templateId}/lists`, data);
}
addItem(
templateId: string,
data: AddListTemplateItemRequest,
): Observable<ListTemplate> {
return this.http.post<ListTemplate>(`${this.apiUrl}/${templateId}/items`, data);
}
deleteItem(templateId: string, itemId: string): Observable<ListTemplate> {
return this.http.delete<ListTemplate>(
`${this.apiUrl}/${templateId}/items/${itemId}`,
);
}
reorderItems(
templateId: string,
data: ReorderListTemplateItemsRequest,
): Observable<ListTemplate> {
return this.http.patch<ListTemplate>(
`${this.apiUrl}/${templateId}/items/order`,
data,
);
}
}

View File

@@ -0,0 +1,171 @@
.workspace-page {
width: min(100%, 1120px);
margin: 0 auto;
padding: 1rem;
}
.page-header {
display: flex;
align-items: stretch;
flex-direction: column;
gap: 1rem;
margin-bottom: 1rem;
}
.page-header h1 {
margin: 0;
font-size: 1.6rem;
font-weight: 500;
}
.page-header p {
margin: 0.35rem 0 0;
color: var(--mat-sys-on-surface-variant);
}
.state-card,
.empty-state {
border-radius: 8px;
background: color-mix(in srgb, var(--mat-sys-surface-container-low) 94%, white);
}
.state-card mat-card-content,
.empty-state mat-card-content {
display: grid;
justify-items: center;
gap: 0.65rem;
padding: 2rem 1rem;
text-align: center;
}
.state-card mat-icon,
.empty-state mat-icon {
width: 48px;
height: 48px;
color: var(--mat-sys-primary);
font-size: 48px;
}
.state-card h2,
.empty-state h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 500;
}
.state-card p,
.empty-state p {
max-width: 460px;
margin: 0;
color: var(--mat-sys-on-surface-variant);
}
.error-state mat-icon {
color: var(--mat-sys-error);
}
.template-grid {
display: grid;
gap: 0.75rem;
}
.template-card {
border-radius: 8px;
background: color-mix(in srgb, var(--mat-sys-surface-container-low) 94%, white);
}
.template-card mat-card-header {
padding-bottom: 0.35rem;
}
.template-description {
margin: 0.5rem 0 0;
color: var(--mat-sys-on-surface-variant);
}
.template-meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 0.85rem;
margin-top: 0.9rem;
color: var(--mat-sys-on-surface-variant);
font-size: 0.85rem;
}
.template-meta span {
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.template-meta mat-icon {
width: 18px;
height: 18px;
font-size: 18px;
}
.template-items {
display: grid;
gap: 0.45rem;
margin: 1rem 0 0;
padding: 0;
list-style: none;
}
.template-items li {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
color: var(--mat-sys-on-surface);
}
.template-items li mat-icon {
flex: 0 0 auto;
width: 18px;
height: 18px;
color: var(--mat-sys-primary);
font-size: 18px;
}
.template-items li span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (min-width: 701px) {
.workspace-page {
padding: 2rem;
}
.page-header {
align-items: center;
flex-direction: row;
justify-content: space-between;
margin-bottom: 1.5rem;
}
.page-header h1 {
font-size: 2rem;
}
.state-card mat-card-content,
.empty-state mat-card-content {
padding: 3rem 1.5rem;
}
mat-card-content {
flex: 1 1 auto;
}
.template-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
}
@media (min-width: 1040px) {
.template-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}

View File

@@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>ListifyClient</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap"
rel="stylesheet"
/>
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
</head>
<body>
<app-root></app-root>
</body>
</html>

View File

@@ -0,0 +1,6 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
bootstrapApplication(App, appConfig)
.catch((err) => console.error(err));

View File

@@ -0,0 +1,39 @@
// Include theming for Angular Material with `mat.theme()`.
// This Sass mixin will define CSS variables that are used for styling Angular Material
// components according to the Material 3 design spec.
// Learn more about theming and how to use it for your application's
// custom components at https://material.angular.dev/guide/theming
@use '@angular/material' as mat;
html {
height: 100%;
@include mat.theme(
(
color: (
primary: mat.$azure-palette,
tertiary: mat.$blue-palette,
),
typography: Roboto,
density: 0,
)
);
}
body {
// Default the application to a light color theme. This can be changed to
// `dark` to enable the dark color theme, or to `light dark` to defer to the
// user's system settings.
color-scheme: light;
// Set a default background, font and text colors for the application using
// Angular Material's system-level CSS variables. Learn more about these
// variables at https://material.angular.dev/guide/system-variables
background-color: var(--mat-sys-surface);
color: var(--mat-sys-on-surface);
font: var(--mat-sys-body-medium);
// Reset the user agent margin.
margin: 0;
height: 100%;
}
/* You can add global styles to this file, and also import other style files */

View File

@@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"include": [
"src/**/*.ts"
],
"exclude": [
"src/**/*.spec.ts"
]
}

View File

@@ -0,0 +1,33 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"compileOnSave": false,
"compilerOptions": {
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"experimentalDecorators": true,
"importHelpers": true,
"target": "ES2022",
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"vitest/globals"
]
},
"include": [
"src/**/*.d.ts",
"src/**/*.spec.ts"
]
}