Initial
This commit is contained in:
17
listify-client/.editorconfig
Normal file
17
listify-client/.editorconfig
Normal 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
44
listify-client/.gitignore
vendored
Normal 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
|
||||
12
listify-client/.prettierrc
Normal file
12
listify-client/.prettierrc
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.html",
|
||||
"options": {
|
||||
"parser": "angular"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
4
listify-client/.vscode/extensions.json
vendored
Normal file
4
listify-client/.vscode/extensions.json
vendored
Normal 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
20
listify-client/.vscode/launch.json
vendored
Normal 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
9
listify-client/.vscode/mcp.json
vendored
Normal 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
42
listify-client/.vscode/tasks.json
vendored
Normal 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
59
listify-client/README.md
Normal 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.
|
||||
82
listify-client/angular.json
Normal file
82
listify-client/angular.json
Normal 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
8725
listify-client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
listify-client/package.json
Normal file
34
listify-client/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
10
listify-client/proxy.conf.json
Normal file
10
listify-client/proxy.conf.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"/api": {
|
||||
"target": "http://localhost:3000",
|
||||
"secure": false,
|
||||
"changeOrigin": true,
|
||||
"pathRewrite": {
|
||||
"^/api": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
listify-client/public/favicon.ico
Normal file
BIN
listify-client/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
22
listify-client/src/app/account/account.component.html
Normal file
22
listify-client/src/app/account/account.component.html
Normal 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>
|
||||
30
listify-client/src/app/account/account.component.scss
Normal file
30
listify-client/src/app/account/account.component.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
22
listify-client/src/app/account/account.component.ts
Normal file
22
listify-client/src/app/account/account.component.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
14
listify-client/src/app/app.config.ts
Normal file
14
listify-client/src/app/app.config.ts
Normal 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)
|
||||
]
|
||||
};
|
||||
130
listify-client/src/app/app.html
Normal file
130
listify-client/src/app/app.html
Normal 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>
|
||||
}
|
||||
38
listify-client/src/app/app.routes.ts
Normal file
38
listify-client/src/app/app.routes.ts
Normal 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' },
|
||||
];
|
||||
198
listify-client/src/app/app.scss
Normal file
198
listify-client/src/app/app.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
25
listify-client/src/app/app.spec.ts
Normal file
25
listify-client/src/app/app.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
47
listify-client/src/app/app.ts
Normal file
47
listify-client/src/app/app.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
78
listify-client/src/app/auth/auth-page.scss
Normal file
78
listify-client/src/app/auth/auth-page.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
10
listify-client/src/app/auth/auth.guard.ts
Normal file
10
listify-client/src/app/auth/auth.guard.ts
Normal 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');
|
||||
};
|
||||
71
listify-client/src/app/auth/auth.interceptor.ts
Normal file
71
listify-client/src/app/auth/auth.interceptor.ts
Normal 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/');
|
||||
}
|
||||
31
listify-client/src/app/auth/auth.models.ts
Normal file
31
listify-client/src/app/auth/auth.models.ts
Normal 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;
|
||||
}
|
||||
102
listify-client/src/app/auth/auth.service.ts
Normal file
102
listify-client/src/app/auth/auth.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
24
listify-client/src/app/auth/error-message.ts
Normal file
24
listify-client/src/app/auth/error-message.ts
Normal 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.';
|
||||
}
|
||||
60
listify-client/src/app/auth/login/login.component.html
Normal file
60
listify-client/src/app/auth/login/login.component.html
Normal 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>
|
||||
64
listify-client/src/app/auth/login/login.component.ts
Normal file
64
listify-client/src/app/auth/login/login.component.ts
Normal 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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
66
listify-client/src/app/auth/register/register.component.html
Normal file
66
listify-client/src/app/auth/register/register.component.html
Normal 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>
|
||||
65
listify-client/src/app/auth/register/register.component.ts
Normal file
65
listify-client/src/app/auth/register/register.component.ts
Normal 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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
90
listify-client/src/app/lists/lists.component.html
Normal file
90
listify-client/src/app/lists/lists.component.html
Normal 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>
|
||||
68
listify-client/src/app/lists/lists.component.ts
Normal file
68
listify-client/src/app/lists/lists.component.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
56
listify-client/src/app/lists/lists.models.ts
Normal file
56
listify-client/src/app/lists/lists.models.ts
Normal 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;
|
||||
}
|
||||
47
listify-client/src/app/lists/lists.service.ts
Normal file
47
listify-client/src/app/lists/lists.service.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
116
listify-client/src/app/templates/templates.component.html
Normal file
116
listify-client/src/app/templates/templates.component.html
Normal 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>
|
||||
142
listify-client/src/app/templates/templates.component.ts
Normal file
142
listify-client/src/app/templates/templates.component.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
76
listify-client/src/app/templates/templates.models.ts
Normal file
76
listify-client/src/app/templates/templates.models.ts
Normal 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;
|
||||
}
|
||||
71
listify-client/src/app/templates/templates.service.ts
Normal file
71
listify-client/src/app/templates/templates.service.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
171
listify-client/src/app/workspace-page.scss
Normal file
171
listify-client/src/app/workspace-page.scss
Normal 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));
|
||||
}
|
||||
}
|
||||
20
listify-client/src/index.html
Normal file
20
listify-client/src/index.html
Normal 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>
|
||||
6
listify-client/src/main.ts
Normal file
6
listify-client/src/main.ts
Normal 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));
|
||||
39
listify-client/src/styles.scss
Normal file
39
listify-client/src/styles.scss
Normal 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 */
|
||||
15
listify-client/tsconfig.app.json
Normal file
15
listify-client/tsconfig.app.json
Normal 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"
|
||||
]
|
||||
}
|
||||
33
listify-client/tsconfig.json
Normal file
33
listify-client/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
15
listify-client/tsconfig.spec.json
Normal file
15
listify-client/tsconfig.spec.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user