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
/typings
__screenshots__/
/playwright-report
/test-results
# System files
.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:
```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.
@@ -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:
```bash
ng test
bun run test -- --watch=false
```
## Running end-to-end tests
@@ -49,10 +49,12 @@ ng test
For end-to-end (e2e) testing, run:
```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
+3 -3
View File
@@ -2,7 +2,7 @@
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm",
"packageManager": "bun",
"schematicCollections": ["angular-eslint"]
},
"newProjectRoot": "projects",
@@ -25,7 +25,7 @@
"input": "public"
}
],
"styles": ["src/styles.css"]
"styles": ["src/styles.scss"]
},
"configurations": {
"production": {
@@ -37,7 +37,7 @@
},
{
"type": "anyComponentStyle",
"maximumWarning": "10kB",
"maximumWarning": "15kB",
"maximumError": "20kB"
}
],
+20
View File
@@ -5,10 +5,12 @@
"": {
"name": "lmnp-frontend",
"dependencies": {
"@angular/cdk": "^22.0.0",
"@angular/common": "^22.0.0",
"@angular/compiler": "^22.0.0",
"@angular/core": "^22.0.0",
"@angular/forms": "^22.0.0",
"@angular/material": "^22.0.0",
"@angular/platform-browser": "^22.0.0",
"@angular/router": "^22.0.0",
"rxjs": "~7.8.0",
@@ -19,6 +21,8 @@
"@angular/cli": "^22.0.6",
"@angular/compiler-cli": "^22.0.0",
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.61.1",
"@types/node": "^24.0.0",
"@vitest/coverage-v8": "^4.1.10",
"angular-eslint": "22.1.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/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/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/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/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=="],
"@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-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/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/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=="],
"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-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-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"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=="],
@@ -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=="],
"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=="],
"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",
"watch": "ng build --watch --configuration development",
"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"
},
"private": true,
"packageManager": "npm@12.0.1",
"packageManager": "bun@1.3.14",
"dependencies": {
"@angular/cdk": "^22.0.0",
"@angular/common": "^22.0.0",
"@angular/compiler": "^22.0.0",
"@angular/core": "^22.0.0",
"@angular/forms": "^22.0.0",
"@angular/material": "^22.0.0",
"@angular/platform-browser": "^22.0.0",
"@angular/router": "^22.0.0",
"rxjs": "~7.8.0",
@@ -26,6 +31,8 @@
"@angular/cli": "^22.0.6",
"@angular/compiler-cli": "^22.0.0",
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.61.1",
"@types/node": "^24.0.0",
"@vitest/coverage-v8": "^4.1.10",
"angular-eslint": "22.1.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">
<div class="background-decorations">
<div class="blob blob-1"></div>
<div class="blob blob-2"></div>
</div>
<main class="main-card">
<header class="card-header">
<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>
@if (!isLoggedIn) {
<main class="sign-in-shell">
<section class="sign-in-panel">
<div class="brand-lockup">
<app-brand-mark class="brand-mark" />
<div>
<h1>LMNP Organizer</h1>
<p>Gestion locative & fiscalité</p>
</div>
} @else {
<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>
</div>
<!-- Tab Content: Dashboard -->
@if (activeTab() === 'dashboard') {
<div class="tab-pane">
<div class="user-profile">
<div class="avatar">
{{ username.charAt(0).toUpperCase() }}
<div class="sign-in-copy">
<span class="context-label">Espace bailleur</span>
<h2>Votre gestion locative, enfin lisible.</h2>
<p>
Pilotez vos appartements et gardez vos démarches LMNP au même endroit, sur mobile comme
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>
<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">
<p class="eyebrow">Compte actif</p>
<h3>{{ username }}</h3>
<p class="user-email">{{ email }}</p>
<span class="badge badge-success">Bailleur Connecté</span>
<span class="role-chip">Bailleur connecté</span>
</div>
</div>
</article>
<div class="api-demo-section">
<h4>Vérification API Sécurisée</h4>
<p>Testez la transmission et la validation du jeton JWT auprès du backend Java 25.</p>
<article class="feature-card api-demo-section">
<div>
<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">
<button (click)="callHelloApi()" class="btn btn-secondary">
Appeler /api/hello
<button mat-stroked-button type="button" (click)="callHelloApi()">
Tester la connexion
</button>
</div>
@if (helloMessage()) {
<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>
</div>
}
</div>
</article>
</div>
}
</section>
}
<!-- Tab Content: Apartments -->
@if (activeTab() === 'apartments') {
<div class="tab-pane">
@if (!showForm()) {
<div class="section-header">
<h2>Mon Portefeuille de Biens</h2>
<button (click)="openAddForm()" class="btn btn-primary btn-sm">
<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">
<path d="M5 12h14"/>
<path d="M12 5v14"/>
</svg>
<span>Ajouter un appartement</span>
</button>
@if (activeTab() === 'apartments') {
<section class="page-section apartments-page">
@if (!showForm()) {
<header class="page-heading">
<div>
<p class="eyebrow">Portefeuille</p>
<h1>Mes appartements</h1>
<p>Les biens rattachés à votre activité LMNP.</p>
</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) {
<div 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">
<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"/>
@if (apartments().length === 0) {
<article class="empty-state">
<div class="empty-illustration" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
<path d="m4 10 8-6 8 6v10H4z" />
<path d="M9 20v-6h6v6" />
</svg>
<h3>Aucun appartement enregistré</h3>
<p>Commencez par ajouter votre premier bien immobilier LMNP à votre portefeuille.</p>
</div>
} @else {
<div class="apartments-grid">
@for (apt of apartments(); track apt.id) {
<div class="apartment-card">
<div class="apt-info">
<h3>{{ apt.name }}</h3>
<p class="apt-address">
<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>
}
<p class="eyebrow">Commencez ici</p>
<h2>Aucun appartement enregistré</h2>
<p>Ajoutez votre premier bien pour construire votre portefeuille.</p>
<button mat-stroked-button type="button" (click)="openAddForm()">
Ajouter un appartement
</button>
</article>
} @else {
<div class="form-container">
<h2>{{ editingApartment() ? 'Modifier l\'appartement' : 'Ajouter un appartement' }}</h2>
@if (formError) {
<div class="alert alert-error">
{{ formError }}
</div>
<div class="apartments-grid">
@for (apt of apartments(); track apt.id) {
<article class="apartment-card">
<div class="apartment-card-topline">
<span class="property-label">Bien immobilier</span>
<span class="property-value"
>{{ 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>
}
} @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">
Se déconnecter
</button>
</div>
}
</section>
<form (submit)="saveApartment(); $event.preventDefault()" class="apartment-form">
@if (formError) {
<div class="alert-error" role="alert">{{ formError }}</div>
}
<footer class="card-footer">
<p>LMNP Organizer &copy; 2026. Sécurisé avec OpenID Connect et Spring Boot 4.</p>
</footer>
</main>
</div>
<div class="form-grid">
<mat-form-field appearance="outline">
<mat-label>Nom de l'appartement</mat-label>
<input
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 { ApartmentService, Apartment } from './apartment.service';
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({
selector: 'app-root',
imports: [FormsModule],
imports: [
BrandMark,
FormsModule,
MatButtonModule,
MatFormFieldModule,
MatInputModule,
MatRadioModule,
],
templateUrl: './app.html',
styleUrl: './app.css'
styleUrl: './app.css',
})
export class App implements OnInit {
protected readonly title = signal('LMNP Organizer');
readonly helloMessage = signal<string>('');
// Tab & CRUD States
readonly activeTab = signal<'dashboard' | 'apartments'>('dashboard');
readonly activeTab = signal<'dashboard' | 'apartments' | 'settings'>('dashboard');
readonly apartments = signal<Apartment[]>([]);
readonly showForm = signal<boolean>(false);
readonly editingApartment = signal<Apartment | null>(null);
@@ -29,9 +42,12 @@ export class App implements OnInit {
private readonly _isLoggedIn = signal<boolean>(false);
private readonly _username = signal<string>('');
private readonly _email = signal<string>('');
readonly userInitial = computed(() => this._username().charAt(0).toUpperCase());
private readonly http = inject(HttpClient);
private readonly apartmentService = inject(ApartmentService);
protected readonly themeService = inject(ThemeService);
readonly themePreference = this.themeService.preference;
constructor() {
effect(() => {
@@ -42,25 +58,27 @@ export class App implements OnInit {
}
ngOnInit() {
this.http.get<{ authenticated: boolean, username?: string, email?: string }>('/api/user').subscribe({
next: (user) => {
if (user.authenticated) {
this._isLoggedIn.set(true);
this._username.set(user.username || 'User');
this._email.set(user.email || '');
} else {
this.http
.get<{ authenticated: boolean; username?: string; email?: string }>('/api/user')
.subscribe({
next: (user) => {
if (user.authenticated) {
this._isLoggedIn.set(true);
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._username.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 {
@@ -89,7 +107,7 @@ export class App implements OnInit {
error: (err) => {
console.error(err);
this.helloMessage.set('Failed to connect to backend: ' + err.statusText);
}
},
});
}
@@ -97,7 +115,7 @@ export class App implements OnInit {
loadApartments() {
this.apartmentService.getApartments().subscribe({
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(),
address: this.formAddress.trim(),
purchasePrice: this.formPurchasePrice,
description: this.formDescription.trim()
description: this.formDescription.trim(),
};
const editMode = this.editingApartment();
@@ -149,7 +167,7 @@ export class App implements OnInit {
error: (err) => {
console.error(err);
this.formError = 'Erreur lors de la modification.';
}
},
});
} else {
this.apartmentService.createApartment(apartmentData).subscribe({
@@ -160,7 +178,7 @@ export class App implements OnInit {
error: (err) => {
console.error(err);
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 ?')) {
this.apartmentService.deleteApartment(id).subscribe({
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"]
}