feat(frontend): add mobile-first Material redesign (#17)
This commit is contained in:
1 parent
fd3fc0123a
commit
1a70bcd126
15 files changed
+1781
-721
No files matched your search
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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=="],
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large.
Load diff
+442
-180
@@ -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') }} €</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 (€) *</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 © 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
@@ -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),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
/* You can add global styles to this file, and also import other style files */
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["playwright.config.ts", "e2e/**/*.ts"]
|
||||||
|
}
|
||||||
Reference in new issue
Block a user