feat(frontend): add mobile-first Material redesign (#17)

This commit is contained in:
nessar committed 2026-08-17 14:18:14 +02:00
1 parent fd3fc0123a
commit 1a70bcd126
15 files changed
+1781 -721

No files matched your search

+2
View File
@@ -38,6 +38,8 @@ yarn-error.log
testem.log testem.log
/typings /typings
__screenshots__/ __screenshots__/
/playwright-report
/test-results
# System files # System files
.DS_Store .DS_Store
+6 -4
View File
@@ -7,7 +7,7 @@ This project was generated using [Angular CLI](https://github.com/angular/angula
To start a local development server, run: To start a local development server, run:
```bash ```bash
ng serve bun run start
``` ```
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. 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.
@@ -41,7 +41,7 @@ This will compile your project and store the build artifacts in the `dist/` dire
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command: To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
```bash ```bash
ng test bun run test -- --watch=false
``` ```
## Running end-to-end tests ## Running end-to-end tests
@@ -49,10 +49,12 @@ ng test
For end-to-end (e2e) testing, run: For end-to-end (e2e) testing, run:
```bash ```bash
ng e2e bunx playwright install chromium
bun run e2e
``` ```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs. The Playwright suite starts the Angular development server automatically and runs against the
360 px mobile, 768 px tablet, and 1280 px desktop projects defined in `playwright.config.ts`.
## Additional Resources ## Additional Resources
+3 -3
View File
@@ -2,7 +2,7 @@
"$schema": "./node_modules/@angular/cli/lib/config/schema.json", "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1, "version": 1,
"cli": { "cli": {
"packageManager": "npm", "packageManager": "bun",
"schematicCollections": ["angular-eslint"] "schematicCollections": ["angular-eslint"]
}, },
"newProjectRoot": "projects", "newProjectRoot": "projects",
@@ -25,7 +25,7 @@
"input": "public" "input": "public"
} }
], ],
"styles": ["src/styles.css"] "styles": ["src/styles.scss"]
}, },
"configurations": { "configurations": {
"production": { "production": {
@@ -37,7 +37,7 @@
}, },
{ {
"type": "anyComponentStyle", "type": "anyComponentStyle",
"maximumWarning": "10kB", "maximumWarning": "15kB",
"maximumError": "20kB" "maximumError": "20kB"
} }
], ],
+20
View File
@@ -5,10 +5,12 @@
"": { "": {
"name": "lmnp-frontend", "name": "lmnp-frontend",
"dependencies": { "dependencies": {
"@angular/cdk": "^22.0.0",
"@angular/common": "^22.0.0", "@angular/common": "^22.0.0",
"@angular/compiler": "^22.0.0", "@angular/compiler": "^22.0.0",
"@angular/core": "^22.0.0", "@angular/core": "^22.0.0",
"@angular/forms": "^22.0.0", "@angular/forms": "^22.0.0",
"@angular/material": "^22.0.0",
"@angular/platform-browser": "^22.0.0", "@angular/platform-browser": "^22.0.0",
"@angular/router": "^22.0.0", "@angular/router": "^22.0.0",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
@@ -19,6 +21,8 @@
"@angular/cli": "^22.0.6", "@angular/cli": "^22.0.6",
"@angular/compiler-cli": "^22.0.0", "@angular/compiler-cli": "^22.0.0",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@playwright/test": "^1.61.1",
"@types/node": "^24.0.0",
"@vitest/coverage-v8": "^4.1.10", "@vitest/coverage-v8": "^4.1.10",
"angular-eslint": "22.1.0", "angular-eslint": "22.1.0",
"eslint": "^10.6.0", "eslint": "^10.6.0",
@@ -85,6 +89,8 @@
"@angular/build": ["@angular/build@22.0.6", "", { "dependencies": { "@ampproject/remapping": "2.3.0", "@angular-devkit/architect": "0.2200.6", "@babel/core": "7.29.0", "@babel/helper-annotate-as-pure": "7.27.3", "@babel/helper-split-export-declaration": "7.24.7", "@inquirer/confirm": "6.0.12", "@vitejs/plugin-basic-ssl": "2.3.0", "beasties": "0.4.2", "browserslist": "^4.26.0", "esbuild": "0.28.1", "https-proxy-agent": "9.0.0", "jsonc-parser": "3.3.1", "listr2": "10.2.1", "magic-string": "0.30.21", "mrmime": "2.0.1", "parse5-html-rewriting-stream": "8.0.1", "picomatch": "4.0.4", "piscina": "5.2.0", "rollup": "4.60.2", "sass": "1.99.0", "semver": "7.7.4", "source-map-support": "0.5.21", "tinyglobby": "0.2.16", "vite": "7.3.5", "watchpack": "2.5.1" }, "optionalDependencies": { "lmdb": "3.5.4" }, "peerDependencies": { "@angular/compiler": "^22.0.0", "@angular/compiler-cli": "^22.0.0", "@angular/core": "^22.0.0", "@angular/localize": "^22.0.0", "@angular/platform-browser": "^22.0.0", "@angular/platform-server": "^22.0.0", "@angular/service-worker": "^22.0.0", "@angular/ssr": "^22.0.6", "istanbul-lib-instrument": "^6.0.0", "karma": "^6.4.0", "less": "^4.2.0", "ng-packagr": "^22.0.0", "postcss": "^8.4.0", "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", "tslib": "^2.3.0", "typescript": ">=6.0 <6.1", "vitest": "^4.0.8" }, "optionalPeers": ["@angular/core", "@angular/localize", "@angular/platform-browser", "@angular/platform-server", "@angular/service-worker", "@angular/ssr", "istanbul-lib-instrument", "karma", "less", "ng-packagr", "postcss", "tailwindcss", "vitest"] }, "sha512-mIp1snH1haw4pKiJwUmKyp9f31NRUTeUwqrl9W59X3Iv54egOnD1EdCc0jsW7WJVr/JHRZYVt/ooFelUB8uV8g=="], "@angular/build": ["@angular/build@22.0.6", "", { "dependencies": { "@ampproject/remapping": "2.3.0", "@angular-devkit/architect": "0.2200.6", "@babel/core": "7.29.0", "@babel/helper-annotate-as-pure": "7.27.3", "@babel/helper-split-export-declaration": "7.24.7", "@inquirer/confirm": "6.0.12", "@vitejs/plugin-basic-ssl": "2.3.0", "beasties": "0.4.2", "browserslist": "^4.26.0", "esbuild": "0.28.1", "https-proxy-agent": "9.0.0", "jsonc-parser": "3.3.1", "listr2": "10.2.1", "magic-string": "0.30.21", "mrmime": "2.0.1", "parse5-html-rewriting-stream": "8.0.1", "picomatch": "4.0.4", "piscina": "5.2.0", "rollup": "4.60.2", "sass": "1.99.0", "semver": "7.7.4", "source-map-support": "0.5.21", "tinyglobby": "0.2.16", "vite": "7.3.5", "watchpack": "2.5.1" }, "optionalDependencies": { "lmdb": "3.5.4" }, "peerDependencies": { "@angular/compiler": "^22.0.0", "@angular/compiler-cli": "^22.0.0", "@angular/core": "^22.0.0", "@angular/localize": "^22.0.0", "@angular/platform-browser": "^22.0.0", "@angular/platform-server": "^22.0.0", "@angular/service-worker": "^22.0.0", "@angular/ssr": "^22.0.6", "istanbul-lib-instrument": "^6.0.0", "karma": "^6.4.0", "less": "^4.2.0", "ng-packagr": "^22.0.0", "postcss": "^8.4.0", "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", "tslib": "^2.3.0", "typescript": ">=6.0 <6.1", "vitest": "^4.0.8" }, "optionalPeers": ["@angular/core", "@angular/localize", "@angular/platform-browser", "@angular/platform-server", "@angular/service-worker", "@angular/ssr", "istanbul-lib-instrument", "karma", "less", "ng-packagr", "postcss", "tailwindcss", "vitest"] }, "sha512-mIp1snH1haw4pKiJwUmKyp9f31NRUTeUwqrl9W59X3Iv54egOnD1EdCc0jsW7WJVr/JHRZYVt/ooFelUB8uV8g=="],
"@angular/cdk": ["@angular/cdk@22.0.4", "", { "dependencies": { "parse5": "^8.0.0", "tslib": "^2.3.0" }, "peerDependencies": { "@angular/common": "^22.0.0 || ^23.0.0", "@angular/core": "^22.0.0 || ^23.0.0", "@angular/platform-browser": "^22.0.0 || ^23.0.0", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-XaH1ff9tOn4acPh0zWbrmJznfZqZ0KZkw5+DM5BIeM9URmAOI8xdZWmVNyaUpVSmriANFjeGZS+Sfat9S6hLOg=="],
"@angular/cli": ["@angular/cli@22.0.6", "", { "dependencies": { "@angular-devkit/architect": "0.2200.6", "@angular-devkit/core": "22.0.6", "@angular-devkit/schematics": "22.0.6", "@inquirer/prompts": "8.4.2", "@listr2/prompt-adapter-inquirer": "4.2.3", "@modelcontextprotocol/sdk": "1.29.0", "@schematics/angular": "22.0.6", "@yarnpkg/lockfile": "1.1.0", "algoliasearch": "5.52.0", "ini": "6.0.0", "jsonc-parser": "3.3.1", "listr2": "10.2.1", "npm-package-arg": "13.0.2", "pacote": "21.5.1", "parse5-html-rewriting-stream": "8.0.1", "semver": "7.7.4", "yargs": "18.0.0", "zod": "4.4.2" }, "bin": { "ng": "bin/ng.js" } }, "sha512-/sEN1hVcywSML+yWrr/T7gbuGfIrZwKC+WhiOv5GksSQVUrSujWnMX4meRJ5ocG/Ie8UN48LJ7LVDsm32NBt1A=="], "@angular/cli": ["@angular/cli@22.0.6", "", { "dependencies": { "@angular-devkit/architect": "0.2200.6", "@angular-devkit/core": "22.0.6", "@angular-devkit/schematics": "22.0.6", "@inquirer/prompts": "8.4.2", "@listr2/prompt-adapter-inquirer": "4.2.3", "@modelcontextprotocol/sdk": "1.29.0", "@schematics/angular": "22.0.6", "@yarnpkg/lockfile": "1.1.0", "algoliasearch": "5.52.0", "ini": "6.0.0", "jsonc-parser": "3.3.1", "listr2": "10.2.1", "npm-package-arg": "13.0.2", "pacote": "21.5.1", "parse5-html-rewriting-stream": "8.0.1", "semver": "7.7.4", "yargs": "18.0.0", "zod": "4.4.2" }, "bin": { "ng": "bin/ng.js" } }, "sha512-/sEN1hVcywSML+yWrr/T7gbuGfIrZwKC+WhiOv5GksSQVUrSujWnMX4meRJ5ocG/Ie8UN48LJ7LVDsm32NBt1A=="],
"@angular/common": ["@angular/common@22.0.6", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/core": "22.0.6", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-+sC0W0FWGeZ/HKUhueCBX8kXdFQDlStbUSK6WF/32igfNYR/PdUrp2L5bU7q654yjxnMzFWDAB+Uvzv6YI/cQg=="], "@angular/common": ["@angular/common@22.0.6", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/core": "22.0.6", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-+sC0W0FWGeZ/HKUhueCBX8kXdFQDlStbUSK6WF/32igfNYR/PdUrp2L5bU7q654yjxnMzFWDAB+Uvzv6YI/cQg=="],
@@ -97,6 +103,8 @@
"@angular/forms": ["@angular/forms@22.0.6", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "tslib": "^2.3.0", "zod": "^4.0.10" }, "peerDependencies": { "@angular/common": "22.0.6", "@angular/core": "22.0.6", "@angular/platform-browser": "22.0.6", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-pZGMk1TvT+m8z2ZGm90W0sBlo6fhtMAA2baZbMt3HbGRmY23vZv1Orv9npX3Z6a9CCNDY3XBMBvkuj8g7vde9Q=="], "@angular/forms": ["@angular/forms@22.0.6", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "tslib": "^2.3.0", "zod": "^4.0.10" }, "peerDependencies": { "@angular/common": "22.0.6", "@angular/core": "22.0.6", "@angular/platform-browser": "22.0.6", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-pZGMk1TvT+m8z2ZGm90W0sBlo6fhtMAA2baZbMt3HbGRmY23vZv1Orv9npX3Z6a9CCNDY3XBMBvkuj8g7vde9Q=="],
"@angular/material": ["@angular/material@22.0.4", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/cdk": "22.0.4", "@angular/common": "^22.0.0 || ^23.0.0", "@angular/core": "^22.0.0 || ^23.0.0", "@angular/forms": "^22.0.0 || ^23.0.0", "@angular/platform-browser": "^22.0.0 || ^23.0.0", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-htOFlTwMtcB9J4q6yPD9jJIQVJ5LfRub+KAiD4D7UU1iaCknpNuQKRfLrZh/mEdOVACI52jHAOXS3+eTkloXcQ=="],
"@angular/platform-browser": ["@angular/platform-browser@22.0.6", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/animations": "22.0.6", "@angular/common": "22.0.6", "@angular/core": "22.0.6" }, "optionalPeers": ["@angular/animations"] }, "sha512-DyyYRx4jXlfaenf0mwsp9jcIak/c17QCFgJKA7OauSkQnk3dL+voMYEwdigfnTIUdw+2Y9wYDywLeuM6NychNQ=="], "@angular/platform-browser": ["@angular/platform-browser@22.0.6", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/animations": "22.0.6", "@angular/common": "22.0.6", "@angular/core": "22.0.6" }, "optionalPeers": ["@angular/animations"] }, "sha512-DyyYRx4jXlfaenf0mwsp9jcIak/c17QCFgJKA7OauSkQnk3dL+voMYEwdigfnTIUdw+2Y9wYDywLeuM6NychNQ=="],
"@angular/router": ["@angular/router@22.0.6", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/common": "22.0.6", "@angular/core": "22.0.6", "@angular/platform-browser": "22.0.6", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-s0BErBBadCXlvilob8aWke4H1FRlXDbnDfyYkjW6RkUaGQ4jzeEAzH56KsG2hf8NtBtYwkRlsLfOX9s5sPwm6w=="], "@angular/router": ["@angular/router@22.0.6", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/common": "22.0.6", "@angular/core": "22.0.6", "@angular/platform-browser": "22.0.6", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-s0BErBBadCXlvilob8aWke4H1FRlXDbnDfyYkjW6RkUaGQ4jzeEAzH56KsG2hf8NtBtYwkRlsLfOX9s5sPwm6w=="],
@@ -413,6 +421,8 @@
"@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="], "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="],
"@playwright/test": ["@playwright/test@1.61.1", "", { "dependencies": { "playwright": "1.61.1" }, "bin": { "playwright": "cli.js" } }, "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.5", "", { "os": "android", "cpu": "arm64" }, "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ=="], "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.5", "", { "os": "android", "cpu": "arm64" }, "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw=="], "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw=="],
@@ -527,6 +537,8 @@
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.62.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.62.1", "@typescript-eslint/type-utils": "8.62.1", "@typescript-eslint/utils": "8.62.1", "@typescript-eslint/visitor-keys": "8.62.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.62.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA=="], "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.62.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.62.1", "@typescript-eslint/type-utils": "8.62.1", "@typescript-eslint/utils": "8.62.1", "@typescript-eslint/visitor-keys": "8.62.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.62.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.62.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.62.1", "@typescript-eslint/types": "8.62.1", "@typescript-eslint/typescript-estree": "8.62.1", "@typescript-eslint/visitor-keys": "8.62.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.62.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.62.1", "@typescript-eslint/types": "8.62.1", "@typescript-eslint/typescript-estree": "8.62.1", "@typescript-eslint/visitor-keys": "8.62.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA=="],
@@ -1071,6 +1083,10 @@
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
"playwright": ["playwright@1.61.1", "", { "dependencies": { "playwright-core": "1.61.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ=="],
"playwright-core": ["playwright-core@1.61.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg=="],
"postcss": ["postcss@8.5.19", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ=="], "postcss": ["postcss@8.5.19", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ=="],
"postcss-media-query-parser": ["postcss-media-query-parser@0.2.3", "", {}, "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig=="], "postcss-media-query-parser": ["postcss-media-query-parser@0.2.3", "", {}, "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig=="],
@@ -1219,6 +1235,8 @@
"undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="], "undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
@@ -1377,6 +1395,8 @@
"ora/string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="], "ora/string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="],
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"sass/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "sass/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"socks-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "socks-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
+197
View File
@@ -0,0 +1,197 @@
import { expect, Page, test } from '@playwright/test';
async function mockUser(page: Page, authenticated: boolean): Promise<void> {
await page.unroute('**/api/user');
await page.route('**/api/user', async (route) => {
await route.fulfill({
contentType: 'application/json',
json: authenticated
? { authenticated: true, username: 'Alice', email: 'alice@example.com' }
: { authenticated: false, username: '', email: '' },
});
});
}
async function mockEmptyApartmentList(page: Page): Promise<void> {
await page.route('**/api/apartments', async (route) => {
await route.fulfill({ contentType: 'application/json', json: [] });
});
}
async function expectNoHorizontalOverflow(page: Page): Promise<void> {
const hasHorizontalOverflow = await page.evaluate(
() => document.documentElement.scrollWidth > document.documentElement.clientWidth,
);
expect(hasHorizontalOverflow).toBe(false);
}
test.beforeEach(async ({ page }) => {
await mockUser(page, false);
});
test('renders the sign-in experience without horizontal overflow', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { name: 'LMNP Organizer' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Se connecter avec OIDC' })).toBeVisible();
await expectNoHorizontalOverflow(page);
});
test('starts the OIDC login flow from the sign-in screen', async ({ page }) => {
await page.route('**/oauth2/authorization/oidc', async (route) => {
await route.fulfill({ contentType: 'text/html', body: '<h1>OIDC provider</h1>' });
});
await page.goto('/');
await page.getByRole('button', { name: 'Se connecter avec OIDC' }).click();
await expect(page).toHaveURL(/\/oauth2\/authorization\/oidc$/);
});
test('persists a dark appearance preference from Settings', async ({ page }) => {
await mockUser(page, true);
await mockEmptyApartmentList(page);
await page.goto('/');
await page.getByRole('button', { name: 'Réglages' }).click();
await page.getByRole('radio', { name: 'Sombre' }).check();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
expect(await page.evaluate(() => localStorage.getItem('lmnp-theme'))).toBe('dark');
await page.reload();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
await page.getByRole('button', { name: 'Réglages' }).click();
await expect(page.getByRole('radio', { name: 'Sombre' })).toBeChecked();
});
test('adapts authenticated navigation to the viewport', async ({ page }, testInfo) => {
await mockUser(page, true);
await mockEmptyApartmentList(page);
await page.goto('/');
const navigation = page.getByTestId('primary-navigation');
await expect(navigation).toBeVisible();
const navigationBox = await navigation.boundingBox();
const viewport = page.viewportSize();
expect(navigationBox).not.toBeNull();
expect(viewport).not.toBeNull();
if (testInfo.project.name === 'desktop') {
expect(navigationBox?.x).toBe(0);
expect(navigationBox?.height).toBe(viewport?.height);
} else {
expect(Math.round((navigationBox?.y ?? 0) + (navigationBox?.height ?? 0))).toBe(
viewport?.height,
);
}
await navigation.getByRole('button', { name: 'Appartements' }).click();
await expect(page.getByRole('heading', { name: 'Mes appartements' })).toBeVisible();
await expectNoHorizontalOverflow(page);
});
test('keeps apartment add and edit workflows usable', async ({ page }) => {
const apartments = [
{
id: 'apt-1',
name: 'T2 République',
address: '12 rue de la République, Lyon',
purchasePrice: 185000,
description: 'Deuxième étage',
},
];
await mockUser(page, true);
await page.route('**/api/apartments**', async (route) => {
const request = route.request();
const method = request.method();
const id = new URL(request.url()).pathname.split('/').at(-1);
if (method === 'POST') {
const apartment = { id: 'apt-2', ...request.postDataJSON() };
apartments.push(apartment);
await route.fulfill({ contentType: 'application/json', json: apartment });
return;
}
if (method === 'PUT') {
const index = apartments.findIndex((apartment) => apartment.id === id);
apartments[index] = { id, ...request.postDataJSON() };
await route.fulfill({ contentType: 'application/json', json: apartments[index] });
return;
}
if (method === 'DELETE') {
const index = apartments.findIndex((apartment) => apartment.id === id);
apartments.splice(index, 1);
await route.fulfill({ status: 204 });
return;
}
await route.fulfill({ contentType: 'application/json', json: apartments });
});
await page.goto('/');
await page
.getByTestId('primary-navigation')
.getByRole('button', { name: 'Appartements' })
.click();
await expect(page.getByRole('heading', { name: 'T2 République' })).toBeVisible();
await page.getByRole('button', { name: 'Modifier' }).click();
await page.getByLabel("Nom de l'appartement").fill('T2 République rénové');
await page.getByRole('button', { name: 'Enregistrer' }).click();
await expect(page.getByRole('heading', { name: 'T2 République rénové' })).toBeVisible();
await page.getByRole('button', { name: 'Ajouter un appartement' }).click();
await page.getByLabel("Nom de l'appartement").fill('Studio Croix-Rousse');
await page.getByLabel('Adresse complète').fill('8 montée de la Boucle, Lyon');
await page.getByLabel("Prix d'acquisition").fill('125000');
await page.getByRole('button', { name: 'Enregistrer' }).click();
await expect(page.getByRole('heading', { name: 'Studio Croix-Rousse' })).toBeVisible();
page.once('dialog', (dialog) => dialog.accept());
const renovatedApartment = page
.getByRole('article')
.filter({ has: page.getByRole('heading', { name: 'T2 République rénové' }) });
await renovatedApartment.getByRole('button', { name: 'Supprimer' }).click();
await expect(page.getByRole('heading', { name: 'T2 République rénové' })).toBeHidden();
await expectNoHorizontalOverflow(page);
});
test('keeps the secured API check and logout available', async ({ page }) => {
await mockUser(page, true);
await mockEmptyApartmentList(page);
await page.route('**/api/hello', async (route) => {
await route.fulfill({ contentType: 'text/plain', body: 'Service disponible' });
});
await page.route('**/logout', async (route) => {
await route.fulfill({ contentType: 'text/html', body: '<h1>Signed out</h1>' });
});
await page.goto('/');
await page.getByRole('button', { name: 'Tester la connexion' }).click();
await expect(page.getByText('Service disponible')).toBeVisible();
await page.getByTestId('primary-navigation').getByRole('button', { name: 'Réglages' }).click();
await page.getByTestId('session-settings').getByRole('button', { name: 'Déconnexion' }).click();
await expect(page).toHaveURL(/\/logout$/);
});
test('follows operating-system changes for the System theme', async ({ page }) => {
await page.addInitScript(() => localStorage.setItem('lmnp-theme', 'system'));
await page.emulateMedia({ colorScheme: 'dark' });
await mockUser(page, true);
await mockEmptyApartmentList(page);
await page.goto('/');
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
await page.emulateMedia({ colorScheme: 'light' });
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
});
+8 -1
View File
@@ -7,15 +7,20 @@
"build": "ng build", "build": "ng build",
"watch": "ng build --watch --configuration development", "watch": "ng build --watch --configuration development",
"test": "ng test", "test": "ng test",
"typecheck": "tsc -p tsconfig.app.json --noEmit && tsc -p tsconfig.e2e.json --noEmit",
"e2e": "playwright test",
"e2e:ui": "playwright test --ui",
"lint": "ng lint" "lint": "ng lint"
}, },
"private": true, "private": true,
"packageManager": "npm@12.0.1", "packageManager": "bun@1.3.14",
"dependencies": { "dependencies": {
"@angular/cdk": "^22.0.0",
"@angular/common": "^22.0.0", "@angular/common": "^22.0.0",
"@angular/compiler": "^22.0.0", "@angular/compiler": "^22.0.0",
"@angular/core": "^22.0.0", "@angular/core": "^22.0.0",
"@angular/forms": "^22.0.0", "@angular/forms": "^22.0.0",
"@angular/material": "^22.0.0",
"@angular/platform-browser": "^22.0.0", "@angular/platform-browser": "^22.0.0",
"@angular/router": "^22.0.0", "@angular/router": "^22.0.0",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
@@ -26,6 +31,8 @@
"@angular/cli": "^22.0.6", "@angular/cli": "^22.0.6",
"@angular/compiler-cli": "^22.0.0", "@angular/compiler-cli": "^22.0.0",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@playwright/test": "^1.61.1",
"@types/node": "^24.0.0",
"@vitest/coverage-v8": "^4.1.10", "@vitest/coverage-v8": "^4.1.10",
"angular-eslint": "22.1.0", "angular-eslint": "22.1.0",
"eslint": "^10.6.0", "eslint": "^10.6.0",
+44
View File
@@ -0,0 +1,44 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: Boolean(process.env['CI']),
retries: process.env['CI'] ? 2 : 0,
workers: process.env['CI'] ? 1 : undefined,
reporter: process.env['CI'] ? 'line' : 'html',
use: {
baseURL: 'http://127.0.0.1:4200',
screenshot: 'only-on-failure',
trace: 'on-first-retry',
},
projects: [
{
name: 'mobile',
use: {
...devices['Desktop Chrome'],
viewport: { width: 360, height: 800 },
},
},
{
name: 'tablet',
use: {
...devices['Desktop Chrome'],
viewport: { width: 768, height: 1024 },
},
},
{
name: 'desktop',
use: {
...devices['Desktop Chrome'],
viewport: { width: 1280, height: 800 },
},
},
],
webServer: {
command: 'bun run start -- --host 127.0.0.1 --port 4200',
url: 'http://127.0.0.1:4200',
reuseExistingServer: !process.env['CI'],
timeout: 120_000,
},
});
+842 -503
View File
File diff suppressed because it is too large. Load diff
+442 -180
View File
@@ -1,208 +1,470 @@
<div class="app-container"> @if (!isLoggedIn) {
<div class="background-decorations"> <main class="sign-in-shell">
<div class="blob blob-1"></div> <section class="sign-in-panel">
<div class="blob blob-2"></div> <div class="brand-lockup">
</div> <app-brand-mark class="brand-mark" />
<div>
<main class="main-card"> <h1>LMNP Organizer</h1>
<header class="card-header"> <p>Gestion locative & fiscalité</p>
<div class="logo-wrapper">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="logo-icon">
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
</div>
<h1>LMNP Organizer</h1>
<p class="subtitle">Votre assistant de gestion et fiscalité immobilière</p>
</header>
<section class="card-body">
@if (!isLoggedIn) {
<div class="auth-state locked">
<div class="shield-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
</div>
<h2>Connexion Sécurisée Requise</h2>
<p>Connectez-vous avec Keycloak / voidAuth pour accéder à votre espace de gestion locative.</p>
<button (click)="login()" class="btn btn-primary btn-glow">
<span>Se connecter avec OIDC</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="btn-arrow">
<path d="M5 12h14"/>
<path d="m12 5 7 7-7 7"/>
</svg>
</button>
</div> </div>
} @else { </div>
<div class="auth-state unlocked">
<!-- Multi-Tab Navigation Menu -->
<nav class="tab-nav">
<button (click)="activeTab.set('dashboard')" [class.active]="activeTab() === 'dashboard'" class="tab-btn">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="tab-icon">
<rect width="7" height="9" x="3" y="3" rx="1"/>
<rect width="7" height="5" x="14" y="3" rx="1"/>
<rect width="7" height="9" x="14" y="12" rx="1"/>
<rect width="7" height="5" x="3" y="16" rx="1"/>
</svg>
<span>Tableau de Bord</span>
</button>
<button (click)="activeTab.set('apartments')" [class.active]="activeTab() === 'apartments'" class="tab-btn">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="tab-icon">
<path d="M2 22h20"/>
<path d="M4 22V4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v18"/>
<path d="M12 18h.01"/>
<path d="M8 6h2"/>
<path d="M14 6h2"/>
<path d="M8 10h2"/>
<path d="M14 10h2"/>
<path d="M8 14h2"/>
<path d="M14 14h2"/>
</svg>
<span>Appartements</span>
</button>
</nav>
<!-- Tab Content: Dashboard --> <div class="sign-in-copy">
@if (activeTab() === 'dashboard') { <span class="context-label">Espace bailleur</span>
<div class="tab-pane"> <h2>Votre gestion locative, enfin lisible.</h2>
<div class="user-profile"> <p>
<div class="avatar"> Pilotez vos appartements et gardez vos démarches LMNP au même endroit, sur mobile comme
{{ username.charAt(0).toUpperCase() }} sur ordinateur.
</p>
</div>
<button mat-flat-button class="primary-action sign-in-action" (click)="login()">
Se connecter avec OIDC
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path d="M5 12h14M13 6l6 6-6 6" />
</svg>
</button>
<p class="security-note">
<span aria-hidden="true">●</span>
Connexion sécurisée via OpenID Connect
</p>
</section>
<aside class="sign-in-story" aria-label="Aperçu du produit">
<div class="story-card">
<p class="story-kicker">Pensé pour aller à l'essentiel</p>
<h2>Un espace calme pour vos décisions de bailleur.</h2>
<div class="story-list">
<div>
<span>01</span>
<p>Vos biens accessibles en quelques gestes.</p>
</div>
<div>
<span>02</span>
<p>Une interface adaptée à chaque écran.</p>
</div>
<div>
<span>03</span>
<p>Vos outils LMNP réunis progressivement.</p>
</div>
</div>
</div>
</aside>
</main>
} @else {
<div class="workspace-shell">
<aside class="app-navigation" data-testid="primary-navigation">
<div class="sidebar-brand">
<app-brand-mark class="brand-mark" />
<div>
<strong>LMNP</strong>
<span>Organizer</span>
</div>
</div>
<nav class="navigation-items" aria-label="Navigation principale">
<button
mat-button
type="button"
class="navigation-item"
[class.active]="activeTab() === 'dashboard'"
[attr.aria-current]="activeTab() === 'dashboard' ? 'page' : null"
(click)="activeTab.set('dashboard')"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
aria-hidden="true"
>
<rect x="3" y="3" width="7" height="7" rx="2" />
<rect x="14" y="3" width="7" height="7" rx="2" />
<rect x="3" y="14" width="7" height="7" rx="2" />
<rect x="14" y="14" width="7" height="7" rx="2" />
</svg>
<span>Tableau de bord</span>
</button>
<button
mat-button
type="button"
class="navigation-item"
[class.active]="activeTab() === 'apartments'"
[attr.aria-current]="activeTab() === 'apartments' ? 'page' : null"
(click)="activeTab.set('apartments')"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
aria-hidden="true"
>
<path d="M4 21V5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v16" />
<path d="M2 21h20M8 7h2M14 7h2M8 11h2M14 11h2M8 15h2M14 15h2" />
</svg>
<span>Appartements</span>
</button>
<button
mat-button
type="button"
class="navigation-item"
[class.active]="activeTab() === 'settings'"
[attr.aria-current]="activeTab() === 'settings' ? 'page' : null"
(click)="activeTab.set('settings')"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
aria-hidden="true"
>
<circle cx="12" cy="12" r="3" />
<path
d="M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06-2.83 2.83-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 .6 1.7 1.7 0 0 0-.4 1.1V21h-4v-.09A1.7 1.7 0 0 0 8.6 19.4a1.7 1.7 0 0 0-1.88.34l-.06.06-2.83-2.83.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-.6-1 1.7 1.7 0 0 0-1.1-.4H3v-4h.09A1.7 1.7 0 0 0 4.6 8.6a1.7 1.7 0 0 0-.34-1.88l-.06-.06 2.83-2.83.06.06A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-.6 1.7 1.7 0 0 0 .4-1.1V3h4v.09A1.7 1.7 0 0 0 15.4 4.6a1.7 1.7 0 0 0 1.88-.34l.06-.06 2.83 2.83-.06.06A1.7 1.7 0 0 0 19.4 9c.16.38.37.72.6 1 .3.3.7.45 1.1.45h.09v4h-.09c-.4 0-.8.15-1.1.45-.23.28-.44.62-.6 1Z"
/>
</svg>
<span>Réglages</span>
</button>
</nav>
<div class="sidebar-user">
<div class="compact-avatar">{{ userInitial() }}</div>
<div>
<strong>{{ username }}</strong>
<span>{{ email }}</span>
</div>
<button mat-button type="button" class="logout-action" (click)="logout()">
Déconnexion
</button>
</div>
</aside>
<main class="workspace-main">
<header class="mobile-header">
<div class="mobile-brand">
<app-brand-mark class="brand-mark" />
<strong>LMNP Organizer</strong>
</div>
<div class="compact-avatar">{{ userInitial() }}</div>
</header>
<div class="page-container">
@if (activeTab() === 'dashboard') {
<section class="page-section dashboard-page">
<header class="page-heading">
<div>
<p class="eyebrow">Bonjour {{ username }}</p>
<h1>Tableau de bord</h1>
<p>Retrouvez rapidement les actions utiles à votre gestion locative.</p>
</div>
<span class="status-pill"><span></span>Espace sécurisé</span>
</header>
<div class="dashboard-grid">
<article class="feature-card feature-card-primary">
<div class="feature-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M4 21V5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v16M2 21h20" />
<path d="M8 8h8M8 12h8M8 16h4" />
</svg>
</div> </div>
<div>
<p class="eyebrow">Votre portefeuille</p>
<h2>
{{ apartments().length }} appartement{{ apartments().length > 1 ? 's' : '' }}
</h2>
<p>Centralisez les informations essentielles de vos biens.</p>
</div>
<button mat-button type="button" (click)="activeTab.set('apartments')">
Gérer mes appartements
</button>
</article>
<article class="feature-card user-profile">
<div class="large-avatar">{{ userInitial() }}</div>
<div class="user-details"> <div class="user-details">
<p class="eyebrow">Compte actif</p>
<h3>{{ username }}</h3> <h3>{{ username }}</h3>
<p class="user-email">{{ email }}</p> <p class="user-email">{{ email }}</p>
<span class="badge badge-success">Bailleur Connecté</span> <span class="role-chip">Bailleur connecté</span>
</div> </div>
</div> </article>
<div class="api-demo-section"> <article class="feature-card api-demo-section">
<h4>Vérification API Sécurisée</h4> <div>
<p>Testez la transmission et la validation du jeton JWT auprès du backend Java 25.</p> <p class="eyebrow">État du service</p>
<h2>Connexion au serveur</h2>
<p>Vérifiez que votre espace communique correctement avec le service sécurisé.</p>
</div>
<div class="api-actions"> <div class="api-actions">
<button (click)="callHelloApi()" class="btn btn-secondary"> <button mat-stroked-button type="button" (click)="callHelloApi()">
Appeler /api/hello Tester la connexion
</button> </button>
</div> </div>
@if (helloMessage()) { @if (helloMessage()) {
<div class="api-response-wrapper"> <div class="api-response-wrapper">
<span class="response-label">Réponse du backend :</span> <span>Réponse du serveur</span>
<code class="api-response-code">{{ helloMessage() }}</code> <code class="api-response-code">{{ helloMessage() }}</code>
</div> </div>
} }
</div> </article>
</div> </div>
} </section>
}
<!-- Tab Content: Apartments --> @if (activeTab() === 'apartments') {
@if (activeTab() === 'apartments') { <section class="page-section apartments-page">
<div class="tab-pane"> @if (!showForm()) {
@if (!showForm()) { <header class="page-heading">
<div class="section-header"> <div>
<h2>Mon Portefeuille de Biens</h2> <p class="eyebrow">Portefeuille</p>
<button (click)="openAddForm()" class="btn btn-primary btn-sm"> <h1>Mes appartements</h1>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="btn-icon"> <p>Les biens rattachés à votre activité LMNP.</p>
<path d="M5 12h14"/>
<path d="M12 5v14"/>
</svg>
<span>Ajouter un appartement</span>
</button>
</div> </div>
<button
mat-flat-button
type="button"
class="primary-action"
(click)="openAddForm()"
>
<span class="button-plus" aria-hidden="true">+</span>
Ajouter un appartement
</button>
</header>
@if (apartments().length === 0) { @if (apartments().length === 0) {
<div class="empty-state"> <article class="empty-state">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="empty-icon"> <div class="empty-illustration" aria-hidden="true">
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
<polyline points="9 22 9 12 15 12 15 22"/> <path d="m4 10 8-6 8 6v10H4z" />
<path d="M9 20v-6h6v6" />
</svg> </svg>
<h3>Aucun appartement enregistré</h3>
<p>Commencez par ajouter votre premier bien immobilier LMNP à votre portefeuille.</p>
</div> </div>
} @else { <p class="eyebrow">Commencez ici</p>
<div class="apartments-grid"> <h2>Aucun appartement enregistré</h2>
@for (apt of apartments(); track apt.id) { <p>Ajoutez votre premier bien pour construire votre portefeuille.</p>
<div class="apartment-card"> <button mat-stroked-button type="button" (click)="openAddForm()">
<div class="apt-info"> Ajouter un appartement
<h3>{{ apt.name }}</h3> </button>
<p class="apt-address"> </article>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="inline-icon">
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/>
<circle cx="12" cy="10" r="3"/>
</svg>
{{ apt.address }}
</p>
<p class="apt-price">{{ apt.purchasePrice.toLocaleString('fr-FR') }} &euro;</p>
@if (apt.description) {
<p class="apt-desc">{{ apt.description }}</p>
}
</div>
<div class="apt-actions">
<button (click)="openEditForm(apt)" class="btn btn-outline btn-xs btn-warning">
Modifier
</button>
@if (apt.id) {
<button (click)="deleteApartment(apt.id)" class="btn btn-outline btn-xs btn-danger">
Supprimer
</button>
}
</div>
</div>
}
</div>
}
} @else { } @else {
<div class="form-container"> <div class="apartments-grid">
<h2>{{ editingApartment() ? 'Modifier l\'appartement' : 'Ajouter un appartement' }}</h2> @for (apt of apartments(); track apt.id) {
<article class="apartment-card">
@if (formError) { <div class="apartment-card-topline">
<div class="alert alert-error"> <span class="property-label">Bien immobilier</span>
{{ formError }} <span class="property-value"
</div> >{{ apt.purchasePrice.toLocaleString('fr-FR') }} €</span
>
</div>
<div class="apt-info">
<h2>{{ apt.name }}</h2>
<p class="apt-address">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
aria-hidden="true"
>
<path d="M20 10c0 5-8 11-8 11S4 15 4 10a8 8 0 1 1 16 0Z" />
<circle cx="12" cy="10" r="2.5" />
</svg>
{{ apt.address }}
</p>
@if (apt.description) {
<p class="apt-desc">{{ apt.description }}</p>
}
</div>
<div class="apt-actions">
<button mat-button type="button" (click)="openEditForm(apt)">
Modifier
</button>
@if (apt.id) {
<button
mat-button
type="button"
class="danger-action"
(click)="deleteApartment(apt.id)"
>
Supprimer
</button>
}
</div>
</article>
} }
<form (submit)="saveApartment(); $event.preventDefault()" class="crud-form">
<div class="form-group">
<label for="aptName">Nom de l'appartement *</label>
<input type="text" id="aptName" name="name" [(ngModel)]="formName" placeholder="Ex. T2 Cosy Paris 15" required>
</div>
<div class="form-group">
<label for="aptAddress">Adresse complète *</label>
<input type="text" id="aptAddress" name="address" [(ngModel)]="formAddress" placeholder="Ex. 45 rue des Entrepreneurs, 75015 Paris" required>
</div>
<div class="form-group">
<label for="aptPrice">Prix d'acquisition (&euro;) *</label>
<input type="number" id="aptPrice" name="purchasePrice" [(ngModel)]="formPurchasePrice" min="1" placeholder="Ex. 180000" required>
</div>
<div class="form-group">
<label for="aptDesc">Description / Notes</label>
<textarea id="aptDesc" name="description" [(ngModel)]="formDescription" rows="3" placeholder="Ex. T2 en rez-de-chaussée, meublé, cuisine équipée."></textarea>
</div>
<div class="form-actions">
<button type="button" (click)="closeForm()" class="btn btn-outline">Annuler</button>
<button type="submit" class="btn btn-primary">Enregistrer</button>
</div>
</form>
</div> </div>
} }
</div> } @else {
} <div class="form-page">
<header class="page-heading form-heading">
<div>
<button mat-button type="button" class="back-action" (click)="closeForm()">
← Retour
</button>
<p class="eyebrow">Portefeuille</p>
<h1>
{{ editingApartment() ? "Modifier l'appartement" : 'Ajouter un appartement' }}
</h1>
<p>Renseignez les informations principales du bien.</p>
</div>
</header>
<button (click)="logout()" class="btn btn-outline btn-logout"> <form (submit)="saveApartment(); $event.preventDefault()" class="apartment-form">
Se déconnecter @if (formError) {
</button> <div class="alert-error" role="alert">{{ formError }}</div>
</div> }
}
</section>
<footer class="card-footer"> <div class="form-grid">
<p>LMNP Organizer &copy; 2026. Sécurisé avec OpenID Connect et Spring Boot 4.</p> <mat-form-field appearance="outline">
</footer> <mat-label>Nom de l'appartement</mat-label>
</main> <input
</div> matInput
type="text"
name="name"
[(ngModel)]="formName"
placeholder="T2 Paris 15"
required
/>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Prix d'acquisition</mat-label>
<input
matInput
type="number"
name="purchasePrice"
[(ngModel)]="formPurchasePrice"
min="1"
required
/>
<span matTextSuffix>€</span>
</mat-form-field>
<mat-form-field appearance="outline" class="full-field">
<mat-label>Adresse complète</mat-label>
<input
matInput
type="text"
name="address"
[(ngModel)]="formAddress"
placeholder="45 rue des Entrepreneurs, 75015 Paris"
required
/>
</mat-form-field>
<mat-form-field appearance="outline" class="full-field">
<mat-label>Description ou notes</mat-label>
<textarea
matInput
name="description"
[(ngModel)]="formDescription"
rows="4"
placeholder="Étage, agencement, équipements…"
></textarea>
</mat-form-field>
</div>
<div class="form-actions">
<button mat-button type="button" (click)="closeForm()">Annuler</button>
<button mat-flat-button type="submit" class="primary-action">
Enregistrer
</button>
</div>
</form>
</div>
}
</section>
}
@if (activeTab() === 'settings') {
<section class="page-section settings-page">
<header class="page-heading">
<div>
<p class="eyebrow">Préférences</p>
<h1>Réglages</h1>
<p>Adaptez LMNP Organizer à votre façon de travailler.</p>
</div>
</header>
<article class="settings-card" aria-labelledby="appearance-heading">
<div class="settings-copy">
<span class="settings-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M12 3a9 9 0 1 0 9 9c0-.5-.04-1-.12-1.47A7 7 0 0 1 12 3Z" />
</svg>
</span>
<div>
<p class="eyebrow">Interface</p>
<h2 id="appearance-heading">Apparence</h2>
<p>Le réglage est mémorisé uniquement sur cet appareil.</p>
</div>
</div>
<mat-radio-group
class="theme-options"
aria-label="Thème de l'application"
[value]="themePreference()"
(change)="themeService.setPreference($event.value)"
>
<mat-radio-button value="light">
<span class="theme-option-copy"
><strong>Clair</strong><small>Une interface lumineuse et douce.</small></span
>
</mat-radio-button>
<mat-radio-button value="dark">
<span class="theme-option-copy"
><strong>Sombre</strong
><small>Un contraste réduit en faible lumière.</small></span
>
</mat-radio-button>
<mat-radio-button value="system">
<span class="theme-option-copy"
><strong>Système</strong
><small>Suit automatiquement votre appareil.</small></span
>
</mat-radio-button>
</mat-radio-group>
</article>
<article
class="settings-card session-settings"
data-testid="session-settings"
aria-labelledby="session-heading"
>
<div class="settings-copy">
<span class="settings-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="8" r="4" />
<path d="M4 21a8 8 0 0 1 16 0" />
</svg>
</span>
<div>
<p class="eyebrow">Compte</p>
<h2 id="session-heading">Session</h2>
<p>Gérez la session ouverte sur cet appareil.</p>
</div>
</div>
<div class="session-action">
<div>
<strong>{{ username }}</strong>
<span>{{ email }}</span>
</div>
<button mat-stroked-button type="button" (click)="logout()">Déconnexion</button>
</div>
</article>
</section>
}
</div>
</main>
</div>
}
+44 -26
View File
@@ -1,20 +1,33 @@
import { Component, signal, inject, OnInit, effect } from '@angular/core'; import { Component, computed, signal, inject, OnInit, effect } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { ApartmentService, Apartment } from './apartment.service'; import { ApartmentService, Apartment } from './apartment.service';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatRadioModule } from '@angular/material/radio';
import { ThemeService } from './theme.service';
import { BrandMark } from './brand-mark';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
imports: [FormsModule], imports: [
BrandMark,
FormsModule,
MatButtonModule,
MatFormFieldModule,
MatInputModule,
MatRadioModule,
],
templateUrl: './app.html', templateUrl: './app.html',
styleUrl: './app.css' styleUrl: './app.css',
}) })
export class App implements OnInit { export class App implements OnInit {
protected readonly title = signal('LMNP Organizer'); protected readonly title = signal('LMNP Organizer');
readonly helloMessage = signal<string>(''); readonly helloMessage = signal<string>('');
// Tab & CRUD States // Tab & CRUD States
readonly activeTab = signal<'dashboard' | 'apartments'>('dashboard'); readonly activeTab = signal<'dashboard' | 'apartments' | 'settings'>('dashboard');
readonly apartments = signal<Apartment[]>([]); readonly apartments = signal<Apartment[]>([]);
readonly showForm = signal<boolean>(false); readonly showForm = signal<boolean>(false);
readonly editingApartment = signal<Apartment | null>(null); readonly editingApartment = signal<Apartment | null>(null);
@@ -29,9 +42,12 @@ export class App implements OnInit {
private readonly _isLoggedIn = signal<boolean>(false); private readonly _isLoggedIn = signal<boolean>(false);
private readonly _username = signal<string>(''); private readonly _username = signal<string>('');
private readonly _email = signal<string>(''); private readonly _email = signal<string>('');
readonly userInitial = computed(() => this._username().charAt(0).toUpperCase());
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
private readonly apartmentService = inject(ApartmentService); private readonly apartmentService = inject(ApartmentService);
protected readonly themeService = inject(ThemeService);
readonly themePreference = this.themeService.preference;
constructor() { constructor() {
effect(() => { effect(() => {
@@ -42,25 +58,27 @@ export class App implements OnInit {
} }
ngOnInit() { ngOnInit() {
this.http.get<{ authenticated: boolean, username?: string, email?: string }>('/api/user').subscribe({ this.http
next: (user) => { .get<{ authenticated: boolean; username?: string; email?: string }>('/api/user')
if (user.authenticated) { .subscribe({
this._isLoggedIn.set(true); next: (user) => {
this._username.set(user.username || 'User'); if (user.authenticated) {
this._email.set(user.email || ''); this._isLoggedIn.set(true);
} else { this._username.set(user.username || 'User');
this._email.set(user.email || '');
} else {
this._isLoggedIn.set(false);
this._username.set('');
this._email.set('');
}
},
error: (err) => {
console.error('Error fetching user authentication status', err);
this._isLoggedIn.set(false); this._isLoggedIn.set(false);
this._username.set(''); this._username.set('');
this._email.set(''); this._email.set('');
} },
}, });
error: (err) => {
console.error('Error fetching user authentication status', err);
this._isLoggedIn.set(false);
this._username.set('');
this._email.set('');
}
});
} }
get isLoggedIn(): boolean { get isLoggedIn(): boolean {
@@ -89,7 +107,7 @@ export class App implements OnInit {
error: (err) => { error: (err) => {
console.error(err); console.error(err);
this.helloMessage.set('Failed to connect to backend: ' + err.statusText); this.helloMessage.set('Failed to connect to backend: ' + err.statusText);
} },
}); });
} }
@@ -97,7 +115,7 @@ export class App implements OnInit {
loadApartments() { loadApartments() {
this.apartmentService.getApartments().subscribe({ this.apartmentService.getApartments().subscribe({
next: (data) => this.apartments.set(data), next: (data) => this.apartments.set(data),
error: (err) => console.error('Error loading apartments', err) error: (err) => console.error('Error loading apartments', err),
}); });
} }
@@ -136,7 +154,7 @@ export class App implements OnInit {
name: this.formName.trim(), name: this.formName.trim(),
address: this.formAddress.trim(), address: this.formAddress.trim(),
purchasePrice: this.formPurchasePrice, purchasePrice: this.formPurchasePrice,
description: this.formDescription.trim() description: this.formDescription.trim(),
}; };
const editMode = this.editingApartment(); const editMode = this.editingApartment();
@@ -149,7 +167,7 @@ export class App implements OnInit {
error: (err) => { error: (err) => {
console.error(err); console.error(err);
this.formError = 'Erreur lors de la modification.'; this.formError = 'Erreur lors de la modification.';
} },
}); });
} else { } else {
this.apartmentService.createApartment(apartmentData).subscribe({ this.apartmentService.createApartment(apartmentData).subscribe({
@@ -160,7 +178,7 @@ export class App implements OnInit {
error: (err) => { error: (err) => {
console.error(err); console.error(err);
this.formError = 'Erreur lors de la création.'; this.formError = 'Erreur lors de la création.';
} },
}); });
} }
} }
@@ -169,7 +187,7 @@ export class App implements OnInit {
if (confirm('Êtes-vous sûr de vouloir supprimer cet appartement ?')) { if (confirm('Êtes-vous sûr de vouloir supprimer cet appartement ?')) {
this.apartmentService.deleteApartment(id).subscribe({ this.apartmentService.deleteApartment(id).subscribe({
next: () => this.loadApartments(), next: () => this.loadApartments(),
error: (err) => console.error('Error deleting apartment', err) error: (err) => console.error('Error deleting apartment', err),
}); });
} }
} }
+24
View File
@@ -0,0 +1,24 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-brand-mark',
template: `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="m4 10 8-6 8 6v9a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1z" />
<path d="M9 20v-6h6v6" />
</svg>
`,
styles: `
:host {
display: grid;
place-items: center;
}
svg {
width: 55%;
height: 55%;
}
`,
host: { 'aria-hidden': 'true' },
})
export class BrandMark {}
+45
View File
@@ -0,0 +1,45 @@
import { DOCUMENT } from '@angular/common';
import { effect, inject, Injectable, signal } from '@angular/core';
export type ThemePreference = 'light' | 'dark' | 'system';
const THEME_STORAGE_KEY = 'lmnp-theme';
@Injectable({ providedIn: 'root' })
export class ThemeService {
private readonly document = inject(DOCUMENT);
private readonly colorSchemeQuery =
typeof window.matchMedia === 'function'
? window.matchMedia('(prefers-color-scheme: dark)')
: null;
private readonly systemUsesDark = signal(this.colorSchemeQuery?.matches ?? false);
readonly preference = signal<ThemePreference>(this.readPreference());
constructor() {
this.colorSchemeQuery?.addEventListener('change', (event) =>
this.systemUsesDark.set(event.matches),
);
effect(() => {
const preference = this.preference();
const resolvedTheme =
preference === 'system' ? (this.systemUsesDark() ? 'dark' : 'light') : preference;
localStorage.setItem(THEME_STORAGE_KEY, preference);
this.document.documentElement.dataset['theme'] = resolvedTheme;
this.document.documentElement.dataset['themePreference'] = preference;
});
}
setPreference(preference: ThemePreference): void {
this.preference.set(preference);
}
private readPreference(): ThemePreference {
const storedPreference = localStorage.getItem(THEME_STORAGE_KEY);
return storedPreference === 'dark' || storedPreference === 'system'
? storedPreference
: 'light';
}
}
-4
View File
@@ -1,4 +0,0 @@
/* You can add global styles to this file, and also import other style files */
body {
margin: 0;
}
+95
View File
@@ -0,0 +1,95 @@
@use '@angular/material' as mat;
@include mat.core();
html {
@include mat.theme(
(
color: (
primary: mat.$cyan-palette,
tertiary: mat.$azure-palette,
theme-type: color-scheme,
),
typography: (
Inter,
ui-sans-serif,
system-ui,
sans-serif,
),
density: 0,
)
);
color-scheme: light;
--canvas: #f4f6f2;
--surface: #ffffff;
--surface-raised: #ffffff;
--surface-soft: #e9efeb;
--surface-accent: #dcefeb;
--text: #17211e;
--text-muted: #687670;
--text-subtle: #8c9994;
--accent: #0f766e;
--accent-strong: #0a5f59;
--accent-contrast: #ffffff;
--border: #dce5df;
--border-strong: #c7d3cd;
--danger: #b42318;
--danger-soft: #fee9e7;
--shadow: 0 24px 60px rgba(28, 49, 42, 0.09);
}
html[data-theme='dark'] {
color-scheme: dark;
--canvas: #101513;
--surface: #171d1b;
--surface-raised: #1b2320;
--surface-soft: #202a26;
--surface-accent: #173a36;
--text: #f0f5f2;
--text-muted: #a7b5af;
--text-subtle: #7f8e88;
--accent: #63c7bc;
--accent-strong: #86d7cf;
--accent-contrast: #08201d;
--border: #2c3733;
--border-strong: #3a4944;
--danger: #ffb4ab;
--danger-soft: #4a2421;
--shadow: 0 24px 60px rgba(0, 0, 0, 0.3);
}
* {
box-sizing: border-box;
}
html,
body {
min-width: 320px;
min-height: 100%;
margin: 0;
overflow-x: hidden;
}
body {
background: var(--canvas);
color: var(--text);
font-family:
Inter,
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
text-rendering: optimizeLegibility;
transition:
background-color 180ms ease,
color 180ms ease;
}
button,
input,
textarea {
font: inherit;
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
"strict": true,
"types": ["node"]
},
"include": ["playwright.config.ts", "e2e/**/*.ts"]
}