Initial commitUnverified
cbf84e0 parent: b426aed modified
angular.json +4 -1 | @@ -32,7 +32,10 @@ | ||
| 32 | 32 | "styles": [ |
| 33 | 33 | "src/styles.css" |
| 34 | 34 | ], |
| 35 | - "scripts": [] | |
| 35 | + "scripts": [], | |
| 36 | + "allowedCommonJsDependencies": [ | |
| 37 | + "leaflet" | |
| 38 | + ] | |
| 36 | 39 | }, |
| 37 | 40 | "configurations": { |
| 38 | 41 | "production": { |
| @@ -32,7 +32,10 @@ | |||
| 32 | "styles": [ | 32 | "styles": [ |
| 33 | "src/styles.css" | 33 | "src/styles.css" |
| 34 | ], | 34 | ], |
| 35 | - "scripts": [] | 35 | + "scripts": [], |
| 36 | + "allowedCommonJsDependencies": [ | ||
| 37 | + "leaflet" | ||
| 38 | + ] | ||
| 36 | }, | 39 | }, |
| 37 | "configurations": { | 40 | "configurations": { |
| 38 | "production": { | 41 | "production": { |
modified
package.json +2 -0 | @@ -18,6 +18,7 @@ | ||
| 18 | 18 | "@angular/platform-browser": "^18.2.0", |
| 19 | 19 | "@angular/platform-browser-dynamic": "^18.2.0", |
| 20 | 20 | "@angular/router": "^18.2.0", |
| 21 | + "leaflet": "^1.9.4", | |
| 21 | 22 | "rxjs": "~7.8.0", |
| 22 | 23 | "tslib": "^2.3.0", |
| 23 | 24 | "zone.js": "~0.14.10" |
| @@ -27,6 +28,7 @@ | ||
| 27 | 28 | "@angular/cli": "^18.2.21", |
| 28 | 29 | "@angular/compiler-cli": "^18.2.0", |
| 29 | 30 | "@types/jasmine": "~5.1.0", |
| 31 | + "@types/leaflet": "^1.9.12", | |
| 30 | 32 | "jasmine-core": "~5.2.0", |
| 31 | 33 | "karma": "~6.4.0", |
| 32 | 34 | "karma-chrome-launcher": "~3.2.0", |
| @@ -18,6 +18,7 @@ | |||
| 18 | "@angular/platform-browser": "^18.2.0", | 18 | "@angular/platform-browser": "^18.2.0", |
| 19 | "@angular/platform-browser-dynamic": "^18.2.0", | 19 | "@angular/platform-browser-dynamic": "^18.2.0", |
| 20 | "@angular/router": "^18.2.0", | 20 | "@angular/router": "^18.2.0", |
| 21 | + "leaflet": "^1.9.4", | ||
| 21 | "rxjs": "~7.8.0", | 22 | "rxjs": "~7.8.0", |
| 22 | "tslib": "^2.3.0", | 23 | "tslib": "^2.3.0", |
| 23 | "zone.js": "~0.14.10" | 24 | "zone.js": "~0.14.10" |
| @@ -27,6 +28,7 @@ | |||
| 27 | "@angular/cli": "^18.2.21", | 28 | "@angular/cli": "^18.2.21", |
| 28 | "@angular/compiler-cli": "^18.2.0", | 29 | "@angular/compiler-cli": "^18.2.0", |
| 29 | "@types/jasmine": "~5.1.0", | 30 | "@types/jasmine": "~5.1.0", |
| 31 | + "@types/leaflet": "^1.9.12", | ||
| 30 | "jasmine-core": "~5.2.0", | 32 | "jasmine-core": "~5.2.0", |
| 31 | "karma": "~6.4.0", | 33 | "karma": "~6.4.0", |
| 32 | "karma-chrome-launcher": "~3.2.0", | 34 | "karma-chrome-launcher": "~3.2.0", |
modified
pnpm-lock.yaml +33 -10 | @@ -32,6 +32,9 @@ importers: | ||
| 32 | 32 | '@angular/router': |
| 33 | 33 | specifier: ^18.2.0 |
| 34 | 34 | version: 18.2.14(@angular/common@18.2.14(@angular/core@18.2.14(rxjs@7.8.2)(zone.js@0.14.10))(rxjs@7.8.2))(@angular/core@18.2.14(rxjs@7.8.2)(zone.js@0.14.10))(@angular/platform-browser@18.2.14(@angular/animations@18.2.14(@angular/core@18.2.14(rxjs@7.8.2)(zone.js@0.14.10)))(@angular/common@18.2.14(@angular/core@18.2.14(rxjs@7.8.2)(zone.js@0.14.10))(rxjs@7.8.2))(@angular/core@18.2.14(rxjs@7.8.2)(zone.js@0.14.10)))(rxjs@7.8.2) |
| 35 | + leaflet: | |
| 36 | + specifier: ^1.9.4 | |
| 37 | + version: 1.9.4 | |
| 35 | 38 | rxjs: |
| 36 | 39 | specifier: ~7.8.0 |
| 37 | 40 | version: 7.8.2 |
| @@ -54,6 +57,9 @@ importers: | ||
| 54 | 57 | '@types/jasmine': |
| 55 | 58 | specifier: ~5.1.0 |
| 56 | 59 | version: 5.1.15 |
| 60 | + '@types/leaflet': | |
| 61 | + specifier: ^1.9.12 | |
| 62 | + version: 1.9.22 | |
| 57 | 63 | jasmine-core: |
| 58 | 64 | specifier: ~5.2.0 |
| 59 | 65 | version: 5.2.0 |
| @@ -1559,6 +1565,9 @@ packages: | ||
| 1559 | 1565 | '@types/express@4.17.25': |
| 1560 | 1566 | resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==} |
| 1561 | 1567 | |
| 1568 | + '@types/geojson@7946.0.16': | |
| 1569 | + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} | |
| 1570 | + | |
| 1562 | 1571 | '@types/http-errors@2.0.5': |
| 1563 | 1572 | resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} |
| 1564 | 1573 | |
| @@ -1571,6 +1580,9 @@ packages: | ||
| 1571 | 1580 | '@types/json-schema@7.0.15': |
| 1572 | 1581 | resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} |
| 1573 | 1582 | |
| 1583 | + '@types/leaflet@1.9.22': | |
| 1584 | + resolution: {integrity: sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==} | |
| 1585 | + | |
| 1574 | 1586 | '@types/mime@1.3.5': |
| 1575 | 1587 | resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} |
| 1576 | 1588 | |
| @@ -2833,6 +2845,9 @@ packages: | ||
| 2833 | 2845 | launch-editor@2.14.2: |
| 2834 | 2846 | resolution: {integrity: sha512-NHJEb30MGBCkmZXLZwz25t37QR4aY03ZzPYUJXOJaOa92uWwExI9SBQUuIznBYc1xkD7cJNGX7oc3Nl3sq2kQQ==} |
| 2835 | 2847 | |
| 2848 | + leaflet@1.9.4: | |
| 2849 | + resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==} | |
| 2850 | + | |
| 2836 | 2851 | less-loader@12.2.0: |
| 2837 | 2852 | resolution: {integrity: sha512-MYUxjSQSBUQmowc0l5nPieOYwMzGPUaTzB6inNW/bdPEG9zOL3eAAD1Qw5ZxSPk7we5dMojHwNODYMV1hq4EVg==} |
| 2838 | 2853 | engines: {node: '>= 18.12.0'} |
| @@ -4243,7 +4258,7 @@ snapshots: | ||
| 4243 | 4258 | dependencies: |
| 4244 | 4259 | '@ampproject/remapping': 2.3.0 |
| 4245 | 4260 | '@angular-devkit/architect': 0.1802.21(chokidar@3.6.0) |
| 4246 | - '@angular-devkit/build-webpack': 0.1802.21(chokidar@3.6.0)(webpack-dev-server@5.2.2(webpack@5.94.0(esbuild@0.23.0)(postcss@8.4.41)))(webpack@5.94.0(esbuild@0.23.0)(postcss@8.4.41)) | |
| 4261 | + '@angular-devkit/build-webpack': 0.1802.21(chokidar@3.6.0)(webpack-dev-server@5.2.2(webpack@5.94.0(postcss@8.4.41)))(webpack@5.94.0(esbuild@0.23.0)(postcss@8.4.41)) | |
| 4247 | 4262 | '@angular-devkit/core': 18.2.21(chokidar@3.6.0) |
| 4248 | 4263 | '@angular/build': 18.2.21(@angular/compiler-cli@18.2.14(@angular/compiler@18.2.14(@angular/core@18.2.14(rxjs@7.8.2)(zone.js@0.14.10)))(typescript@5.5.4))(@types/node@26.6.5)(chokidar@3.6.0)(less@4.2.0)(postcss@8.4.41)(terser@5.31.6)(typescript@5.5.4) |
| 4249 | 4264 | '@angular/compiler-cli': 18.2.14(@angular/compiler@18.2.14(@angular/core@18.2.14(rxjs@7.8.2)(zone.js@0.14.10)))(typescript@5.5.4) |
| @@ -4299,8 +4314,8 @@ snapshots: | ||
| 4299 | 4314 | typescript: 5.5.4 |
| 4300 | 4315 | watchpack: 2.4.1 |
| 4301 | 4316 | webpack: 5.94.0(esbuild@0.23.0)(postcss@8.4.41) |
| 4302 | - webpack-dev-middleware: 7.4.2(webpack@5.94.0(esbuild@0.23.0)(postcss@8.4.41)) | |
| 4303 | - webpack-dev-server: 5.2.2(webpack@5.94.0(esbuild@0.23.0)(postcss@8.4.41)) | |
| 4317 | + webpack-dev-middleware: 7.4.2(webpack@5.94.0(postcss@8.4.41)) | |
| 4318 | + webpack-dev-server: 5.2.2(webpack@5.94.0(postcss@8.4.41)) | |
| 4304 | 4319 | webpack-merge: 6.0.1 |
| 4305 | 4320 | webpack-subresource-integrity: 5.1.0(webpack@5.94.0(esbuild@0.23.0)(postcss@8.4.41)) |
| 4306 | 4321 | optionalDependencies: |
| @@ -4331,12 +4346,12 @@ snapshots: | ||
| 4331 | 4346 | - utf-8-validate |
| 4332 | 4347 | - webpack-cli |
| 4333 | 4348 | |
| 4334 | - '@angular-devkit/build-webpack@0.1802.21(chokidar@3.6.0)(webpack-dev-server@5.2.2(webpack@5.94.0(esbuild@0.23.0)(postcss@8.4.41)))(webpack@5.94.0(esbuild@0.23.0)(postcss@8.4.41))': | |
| 4349 | + '@angular-devkit/build-webpack@0.1802.21(chokidar@3.6.0)(webpack-dev-server@5.2.2(webpack@5.94.0(postcss@8.4.41)))(webpack@5.94.0(esbuild@0.23.0)(postcss@8.4.41))': | |
| 4335 | 4350 | dependencies: |
| 4336 | 4351 | '@angular-devkit/architect': 0.1802.21(chokidar@3.6.0) |
| 4337 | 4352 | rxjs: 7.8.1 |
| 4338 | 4353 | webpack: 5.94.0(esbuild@0.23.0)(postcss@8.4.41) |
| 4339 | - webpack-dev-server: 5.2.2(webpack@5.94.0(esbuild@0.23.0)(postcss@8.4.41)) | |
| 4354 | + webpack-dev-server: 5.2.2(webpack@5.94.0(postcss@8.4.41)) | |
| 4340 | 4355 | transitivePeerDependencies: |
| 4341 | 4356 | - chokidar |
| 4342 | 4357 | |
| @@ -5920,6 +5935,8 @@ snapshots: | ||
| 5920 | 5935 | '@types/qs': 6.15.1 |
| 5921 | 5936 | '@types/serve-static': 1.15.10 |
| 5922 | 5937 | |
| 5938 | + '@types/geojson@7946.0.16': {} | |
| 5939 | + | |
| 5923 | 5940 | '@types/http-errors@2.0.5': {} |
| 5924 | 5941 | |
| 5925 | 5942 | '@types/http-proxy@1.17.17': |
| @@ -5930,6 +5947,10 @@ snapshots: | ||
| 5930 | 5947 | |
| 5931 | 5948 | '@types/json-schema@7.0.15': {} |
| 5932 | 5949 | |
| 5950 | + '@types/leaflet@1.9.22': | |
| 5951 | + dependencies: | |
| 5952 | + '@types/geojson': 7946.0.16 | |
| 5953 | + | |
| 5933 | 5954 | '@types/mime@1.3.5': {} |
| 5934 | 5955 | |
| 5935 | 5956 | '@types/mute-stream@0.0.4': |
| @@ -7349,6 +7370,8 @@ snapshots: | ||
| 7349 | 7370 | picocolors: 1.1.1 |
| 7350 | 7371 | shell-quote: 1.12.0 |
| 7351 | 7372 | |
| 7373 | + leaflet@1.9.4: {} | |
| 7374 | + | |
| 7352 | 7375 | less-loader@12.2.0(less@4.2.0)(webpack@5.94.0(esbuild@0.23.0)(postcss@8.4.41)): |
| 7353 | 7376 | dependencies: |
| 7354 | 7377 | less: 4.2.0 |
| @@ -8466,7 +8489,7 @@ snapshots: | ||
| 8466 | 8489 | mkdirp: 1.0.4 |
| 8467 | 8490 | yallist: 4.0.0 |
| 8468 | 8491 | |
| 8469 | - terser-webpack-plugin@5.6.1(esbuild@0.23.0)(postcss@8.4.41)(webpack@5.94.0(esbuild@0.23.0)(postcss@8.4.41)): | |
| 8492 | + terser-webpack-plugin@5.6.1(esbuild@0.23.0)(postcss@8.4.41)(webpack@5.94.0(postcss@8.4.41)): | |
| 8470 | 8493 | dependencies: |
| 8471 | 8494 | '@jridgewell/trace-mapping': 0.3.31 |
| 8472 | 8495 | jest-worker: 27.5.1 |
| @@ -8616,7 +8639,7 @@ snapshots: | ||
| 8616 | 8639 | |
| 8617 | 8640 | weak-lru-cache@1.2.2: {} |
| 8618 | 8641 | |
| 8619 | - webpack-dev-middleware@7.4.2(webpack@5.94.0(esbuild@0.23.0)(postcss@8.4.41)): | |
| 8642 | + webpack-dev-middleware@7.4.2(webpack@5.94.0(postcss@8.4.41)): | |
| 8620 | 8643 | dependencies: |
| 8621 | 8644 | colorette: 2.0.20 |
| 8622 | 8645 | memfs: 4.80.0 |
| @@ -8627,7 +8650,7 @@ snapshots: | ||
| 8627 | 8650 | optionalDependencies: |
| 8628 | 8651 | webpack: 5.94.0(esbuild@0.23.0)(postcss@8.4.41) |
| 8629 | 8652 | |
| 8630 | - webpack-dev-server@5.2.2(webpack@5.94.0(esbuild@0.23.0)(postcss@8.4.41)): | |
| 8653 | + webpack-dev-server@5.2.2(webpack@5.94.0(postcss@8.4.41)): | |
| 8631 | 8654 | dependencies: |
| 8632 | 8655 | '@types/bonjour': 3.5.13 |
| 8633 | 8656 | '@types/connect-history-api-fallback': 1.5.4 |
| @@ -8655,7 +8678,7 @@ snapshots: | ||
| 8655 | 8678 | serve-index: 1.9.2 |
| 8656 | 8679 | sockjs: 0.3.24 |
| 8657 | 8680 | spdy: 4.0.2 |
| 8658 | - webpack-dev-middleware: 7.4.2(webpack@5.94.0(esbuild@0.23.0)(postcss@8.4.41)) | |
| 8681 | + webpack-dev-middleware: 7.4.2(webpack@5.94.0(postcss@8.4.41)) | |
| 8659 | 8682 | ws: 8.22.0 |
| 8660 | 8683 | optionalDependencies: |
| 8661 | 8684 | webpack: 5.94.0(esbuild@0.23.0)(postcss@8.4.41) |
| @@ -8700,7 +8723,7 @@ snapshots: | ||
| 8700 | 8723 | neo-async: 2.6.2 |
| 8701 | 8724 | schema-utils: 3.3.0 |
| 8702 | 8725 | tapable: 2.3.3 |
| 8703 | - terser-webpack-plugin: 5.6.1(esbuild@0.23.0)(postcss@8.4.41)(webpack@5.94.0(esbuild@0.23.0)(postcss@8.4.41)) | |
| 8726 | + terser-webpack-plugin: 5.6.1(esbuild@0.23.0)(postcss@8.4.41)(webpack@5.94.0(postcss@8.4.41)) | |
| 8704 | 8727 | watchpack: 2.4.1 |
| 8705 | 8728 | webpack-sources: 3.6.0 |
| 8706 | 8729 | transitivePeerDependencies: |
| @@ -32,6 +32,9 @@ importers: | |||
| 32 | '@angular/router': | 32 | '@angular/router': |
| 33 | specifier: ^18.2.0 | 33 | specifier: ^18.2.0 |
| 34 | version: 18.2.14(@angular/common@18.2.14(@angular/core@18.2.14(rxjs@7.8.2)(zone.js@0.14.10))(rxjs@7.8.2))(@angular/core@18.2.14(rxjs@7.8.2)(zone.js@0.14.10))(@angular/platform-browser@18.2.14(@angular/animations@18.2.14(@angular/core@18.2.14(rxjs@7.8.2)(zone.js@0.14.10)))(@angular/common@18.2.14(@angular/core@18.2.14(rxjs@7.8.2)(zone.js@0.14.10))(rxjs@7.8.2))(@angular/core@18.2.14(rxjs@7.8.2)(zone.js@0.14.10)))(rxjs@7.8.2) | 34 | version: 18.2.14(@angular/common@18.2.14(@angular/core@18.2.14(rxjs@7.8.2)(zone.js@0.14.10))(rxjs@7.8.2))(@angular/core@18.2.14(rxjs@7.8.2)(zone.js@0.14.10))(@angular/platform-browser@18.2.14(@angular/animations@18.2.14(@angular/core@18.2.14(rxjs@7.8.2)(zone.js@0.14.10)))(@angular/common@18.2.14(@angular/core@18.2.14(rxjs@7.8.2)(zone.js@0.14.10))(rxjs@7.8.2))(@angular/core@18.2.14(rxjs@7.8.2)(zone.js@0.14.10)))(rxjs@7.8.2) |
| 35 | + leaflet: | ||
| 36 | + specifier: ^1.9.4 | ||
| 37 | + version: 1.9.4 | ||
| 35 | rxjs: | 38 | rxjs: |
| 36 | specifier: ~7.8.0 | 39 | specifier: ~7.8.0 |
| 37 | version: 7.8.2 | 40 | version: 7.8.2 |
| @@ -54,6 +57,9 @@ importers: | |||
| 54 | '@types/jasmine': | 57 | '@types/jasmine': |
| 55 | specifier: ~5.1.0 | 58 | specifier: ~5.1.0 |
| 56 | version: 5.1.15 | 59 | version: 5.1.15 |
| 60 | + '@types/leaflet': | ||
| 61 | + specifier: ^1.9.12 | ||
| 62 | + version: 1.9.22 | ||
| 57 | jasmine-core: | 63 | jasmine-core: |
| 58 | specifier: ~5.2.0 | 64 | specifier: ~5.2.0 |
| 59 | version: 5.2.0 | 65 | version: 5.2.0 |
| @@ -1559,6 +1565,9 @@ packages: | |||
| 1559 | '@types/express@4.17.25': | 1565 | '@types/express@4.17.25': |
| 1560 | resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==} | 1566 | resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==} |
| 1561 | 1567 | ||
| 1568 | + '@types/geojson@7946.0.16': | ||
| 1569 | + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} | ||
| 1570 | + | ||
| 1562 | '@types/http-errors@2.0.5': | 1571 | '@types/http-errors@2.0.5': |
| 1563 | resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} | 1572 | resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} |
| 1564 | 1573 | ||
| @@ -1571,6 +1580,9 @@ packages: | |||
| 1571 | '@types/json-schema@7.0.15': | 1580 | '@types/json-schema@7.0.15': |
| 1572 | resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} | 1581 | resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} |
| 1573 | 1582 | ||
| 1583 | + '@types/leaflet@1.9.22': | ||
| 1584 | + resolution: {integrity: sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==} | ||
| 1585 | + | ||
| 1574 | '@types/mime@1.3.5': | 1586 | '@types/mime@1.3.5': |
| 1575 | resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} | 1587 | resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} |
| 1576 | 1588 | ||
| @@ -2833,6 +2845,9 @@ packages: | |||
| 2833 | launch-editor@2.14.2: | 2845 | launch-editor@2.14.2: |
| 2834 | resolution: {integrity: sha512-NHJEb30MGBCkmZXLZwz25t37QR4aY03ZzPYUJXOJaOa92uWwExI9SBQUuIznBYc1xkD7cJNGX7oc3Nl3sq2kQQ==} | 2846 | resolution: {integrity: sha512-NHJEb30MGBCkmZXLZwz25t37QR4aY03ZzPYUJXOJaOa92uWwExI9SBQUuIznBYc1xkD7cJNGX7oc3Nl3sq2kQQ==} |
| 2835 | 2847 | ||
| 2848 | + leaflet@1.9.4: | ||
| 2849 | + resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==} | ||
| 2850 | + | ||
| 2836 | less-loader@12.2.0: | 2851 | less-loader@12.2.0: |
| 2837 | resolution: {integrity: sha512-MYUxjSQSBUQmowc0l5nPieOYwMzGPUaTzB6inNW/bdPEG9zOL3eAAD1Qw5ZxSPk7we5dMojHwNODYMV1hq4EVg==} | 2852 | resolution: {integrity: sha512-MYUxjSQSBUQmowc0l5nPieOYwMzGPUaTzB6inNW/bdPEG9zOL3eAAD1Qw5ZxSPk7we5dMojHwNODYMV1hq4EVg==} |
| 2838 | engines: {node: '>= 18.12.0'} | 2853 | engines: {node: '>= 18.12.0'} |
| @@ -4243,7 +4258,7 @@ snapshots: | |||
| 4243 | dependencies: | 4258 | dependencies: |
| 4244 | '@ampproject/remapping': 2.3.0 | 4259 | '@ampproject/remapping': 2.3.0 |
| 4245 | '@angular-devkit/architect': 0.1802.21(chokidar@3.6.0) | 4260 | '@angular-devkit/architect': 0.1802.21(chokidar@3.6.0) |
| 4246 | - '@angular-devkit/build-webpack': 0.1802.21(chokidar@3.6.0)(webpack-dev-server@5.2.2(webpack@5.94.0(esbuild@0.23.0)(postcss@8.4.41)))(webpack@5.94.0(esbuild@0.23.0)(postcss@8.4.41)) | 4261 | + '@angular-devkit/build-webpack': 0.1802.21(chokidar@3.6.0)(webpack-dev-server@5.2.2(webpack@5.94.0(postcss@8.4.41)))(webpack@5.94.0(esbuild@0.23.0)(postcss@8.4.41)) |
| 4247 | '@angular-devkit/core': 18.2.21(chokidar@3.6.0) | 4262 | '@angular-devkit/core': 18.2.21(chokidar@3.6.0) |
| 4248 | '@angular/build': 18.2.21(@angular/compiler-cli@18.2.14(@angular/compiler@18.2.14(@angular/core@18.2.14(rxjs@7.8.2)(zone.js@0.14.10)))(typescript@5.5.4))(@types/node@26.6.5)(chokidar@3.6.0)(less@4.2.0)(postcss@8.4.41)(terser@5.31.6)(typescript@5.5.4) | 4263 | '@angular/build': 18.2.21(@angular/compiler-cli@18.2.14(@angular/compiler@18.2.14(@angular/core@18.2.14(rxjs@7.8.2)(zone.js@0.14.10)))(typescript@5.5.4))(@types/node@26.6.5)(chokidar@3.6.0)(less@4.2.0)(postcss@8.4.41)(terser@5.31.6)(typescript@5.5.4) |
| 4249 | '@angular/compiler-cli': 18.2.14(@angular/compiler@18.2.14(@angular/core@18.2.14(rxjs@7.8.2)(zone.js@0.14.10)))(typescript@5.5.4) | 4264 | '@angular/compiler-cli': 18.2.14(@angular/compiler@18.2.14(@angular/core@18.2.14(rxjs@7.8.2)(zone.js@0.14.10)))(typescript@5.5.4) |
| @@ -4299,8 +4314,8 @@ snapshots: | |||
| 4299 | typescript: 5.5.4 | 4314 | typescript: 5.5.4 |
| 4300 | watchpack: 2.4.1 | 4315 | watchpack: 2.4.1 |
| 4301 | webpack: 5.94.0(esbuild@0.23.0)(postcss@8.4.41) | 4316 | webpack: 5.94.0(esbuild@0.23.0)(postcss@8.4.41) |
| 4302 | - webpack-dev-middleware: 7.4.2(webpack@5.94.0(esbuild@0.23.0)(postcss@8.4.41)) | 4317 | + webpack-dev-middleware: 7.4.2(webpack@5.94.0(postcss@8.4.41)) |
| 4303 | - webpack-dev-server: 5.2.2(webpack@5.94.0(esbuild@0.23.0)(postcss@8.4.41)) | 4318 | + webpack-dev-server: 5.2.2(webpack@5.94.0(postcss@8.4.41)) |
| 4304 | webpack-merge: 6.0.1 | 4319 | webpack-merge: 6.0.1 |
| 4305 | webpack-subresource-integrity: 5.1.0(webpack@5.94.0(esbuild@0.23.0)(postcss@8.4.41)) | 4320 | webpack-subresource-integrity: 5.1.0(webpack@5.94.0(esbuild@0.23.0)(postcss@8.4.41)) |
| 4306 | optionalDependencies: | 4321 | optionalDependencies: |
| @@ -4331,12 +4346,12 @@ snapshots: | |||
| 4331 | - utf-8-validate | 4346 | - utf-8-validate |
| 4332 | - webpack-cli | 4347 | - webpack-cli |
| 4333 | 4348 | ||
| 4334 | - '@angular-devkit/build-webpack@0.1802.21(chokidar@3.6.0)(webpack-dev-server@5.2.2(webpack@5.94.0(esbuild@0.23.0)(postcss@8.4.41)))(webpack@5.94.0(esbuild@0.23.0)(postcss@8.4.41))': | 4349 | + '@angular-devkit/build-webpack@0.1802.21(chokidar@3.6.0)(webpack-dev-server@5.2.2(webpack@5.94.0(postcss@8.4.41)))(webpack@5.94.0(esbuild@0.23.0)(postcss@8.4.41))': |
| 4335 | dependencies: | 4350 | dependencies: |
| 4336 | '@angular-devkit/architect': 0.1802.21(chokidar@3.6.0) | 4351 | '@angular-devkit/architect': 0.1802.21(chokidar@3.6.0) |
| 4337 | rxjs: 7.8.1 | 4352 | rxjs: 7.8.1 |
| 4338 | webpack: 5.94.0(esbuild@0.23.0)(postcss@8.4.41) | 4353 | webpack: 5.94.0(esbuild@0.23.0)(postcss@8.4.41) |
| 4339 | - webpack-dev-server: 5.2.2(webpack@5.94.0(esbuild@0.23.0)(postcss@8.4.41)) | 4354 | + webpack-dev-server: 5.2.2(webpack@5.94.0(postcss@8.4.41)) |
| 4340 | transitivePeerDependencies: | 4355 | transitivePeerDependencies: |
| 4341 | - chokidar | 4356 | - chokidar |
| 4342 | 4357 | ||
| @@ -5920,6 +5935,8 @@ snapshots: | |||
| 5920 | '@types/qs': 6.15.1 | 5935 | '@types/qs': 6.15.1 |
| 5921 | '@types/serve-static': 1.15.10 | 5936 | '@types/serve-static': 1.15.10 |
| 5922 | 5937 | ||
| 5938 | + '@types/geojson@7946.0.16': {} | ||
| 5939 | + | ||
| 5923 | '@types/http-errors@2.0.5': {} | 5940 | '@types/http-errors@2.0.5': {} |
| 5924 | 5941 | ||
| 5925 | '@types/http-proxy@1.17.17': | 5942 | '@types/http-proxy@1.17.17': |
| @@ -5930,6 +5947,10 @@ snapshots: | |||
| 5930 | 5947 | ||
| 5931 | '@types/json-schema@7.0.15': {} | 5948 | '@types/json-schema@7.0.15': {} |
| 5932 | 5949 | ||
| 5950 | + '@types/leaflet@1.9.22': | ||
| 5951 | + dependencies: | ||
| 5952 | + '@types/geojson': 7946.0.16 | ||
| 5953 | + | ||
| 5933 | '@types/mime@1.3.5': {} | 5954 | '@types/mime@1.3.5': {} |
| 5934 | 5955 | ||
| 5935 | '@types/mute-stream@0.0.4': | 5956 | '@types/mute-stream@0.0.4': |
| @@ -7349,6 +7370,8 @@ snapshots: | |||
| 7349 | picocolors: 1.1.1 | 7370 | picocolors: 1.1.1 |
| 7350 | shell-quote: 1.12.0 | 7371 | shell-quote: 1.12.0 |
| 7351 | 7372 | ||
| 7373 | + leaflet@1.9.4: {} | ||
| 7374 | + | ||
| 7352 | less-loader@12.2.0(less@4.2.0)(webpack@5.94.0(esbuild@0.23.0)(postcss@8.4.41)): | 7375 | less-loader@12.2.0(less@4.2.0)(webpack@5.94.0(esbuild@0.23.0)(postcss@8.4.41)): |
| 7353 | dependencies: | 7376 | dependencies: |
| 7354 | less: 4.2.0 | 7377 | less: 4.2.0 |
| @@ -8466,7 +8489,7 @@ snapshots: | |||
| 8466 | mkdirp: 1.0.4 | 8489 | mkdirp: 1.0.4 |
| 8467 | yallist: 4.0.0 | 8490 | yallist: 4.0.0 |
| 8468 | 8491 | ||
| 8469 | - terser-webpack-plugin@5.6.1(esbuild@0.23.0)(postcss@8.4.41)(webpack@5.94.0(esbuild@0.23.0)(postcss@8.4.41)): | 8492 | + terser-webpack-plugin@5.6.1(esbuild@0.23.0)(postcss@8.4.41)(webpack@5.94.0(postcss@8.4.41)): |
| 8470 | dependencies: | 8493 | dependencies: |
| 8471 | '@jridgewell/trace-mapping': 0.3.31 | 8494 | '@jridgewell/trace-mapping': 0.3.31 |
| 8472 | jest-worker: 27.5.1 | 8495 | jest-worker: 27.5.1 |
| @@ -8616,7 +8639,7 @@ snapshots: | |||
| 8616 | 8639 | ||
| 8617 | weak-lru-cache@1.2.2: {} | 8640 | weak-lru-cache@1.2.2: {} |
| 8618 | 8641 | ||
| 8619 | - webpack-dev-middleware@7.4.2(webpack@5.94.0(esbuild@0.23.0)(postcss@8.4.41)): | 8642 | + webpack-dev-middleware@7.4.2(webpack@5.94.0(postcss@8.4.41)): |
| 8620 | dependencies: | 8643 | dependencies: |
| 8621 | colorette: 2.0.20 | 8644 | colorette: 2.0.20 |
| 8622 | memfs: 4.80.0 | 8645 | memfs: 4.80.0 |
| @@ -8627,7 +8650,7 @@ snapshots: | |||
| 8627 | optionalDependencies: | 8650 | optionalDependencies: |
| 8628 | webpack: 5.94.0(esbuild@0.23.0)(postcss@8.4.41) | 8651 | webpack: 5.94.0(esbuild@0.23.0)(postcss@8.4.41) |
| 8629 | 8652 | ||
| 8630 | - webpack-dev-server@5.2.2(webpack@5.94.0(esbuild@0.23.0)(postcss@8.4.41)): | 8653 | + webpack-dev-server@5.2.2(webpack@5.94.0(postcss@8.4.41)): |
| 8631 | dependencies: | 8654 | dependencies: |
| 8632 | '@types/bonjour': 3.5.13 | 8655 | '@types/bonjour': 3.5.13 |
| 8633 | '@types/connect-history-api-fallback': 1.5.4 | 8656 | '@types/connect-history-api-fallback': 1.5.4 |
| @@ -8655,7 +8678,7 @@ snapshots: | |||
| 8655 | serve-index: 1.9.2 | 8678 | serve-index: 1.9.2 |
| 8656 | sockjs: 0.3.24 | 8679 | sockjs: 0.3.24 |
| 8657 | spdy: 4.0.2 | 8680 | spdy: 4.0.2 |
| 8658 | - webpack-dev-middleware: 7.4.2(webpack@5.94.0(esbuild@0.23.0)(postcss@8.4.41)) | 8681 | + webpack-dev-middleware: 7.4.2(webpack@5.94.0(postcss@8.4.41)) |
| 8659 | ws: 8.22.0 | 8682 | ws: 8.22.0 |
| 8660 | optionalDependencies: | 8683 | optionalDependencies: |
| 8661 | webpack: 5.94.0(esbuild@0.23.0)(postcss@8.4.41) | 8684 | webpack: 5.94.0(esbuild@0.23.0)(postcss@8.4.41) |
| @@ -8700,7 +8723,7 @@ snapshots: | |||
| 8700 | neo-async: 2.6.2 | 8723 | neo-async: 2.6.2 |
| 8701 | schema-utils: 3.3.0 | 8724 | schema-utils: 3.3.0 |
| 8702 | tapable: 2.3.3 | 8725 | tapable: 2.3.3 |
| 8703 | - terser-webpack-plugin: 5.6.1(esbuild@0.23.0)(postcss@8.4.41)(webpack@5.94.0(esbuild@0.23.0)(postcss@8.4.41)) | 8726 | + terser-webpack-plugin: 5.6.1(esbuild@0.23.0)(postcss@8.4.41)(webpack@5.94.0(postcss@8.4.41)) |
| 8704 | watchpack: 2.4.1 | 8727 | watchpack: 2.4.1 |
| 8705 | webpack-sources: 3.6.0 | 8728 | webpack-sources: 3.6.0 |
| 8706 | transitivePeerDependencies: | 8729 | transitivePeerDependencies: |
modified
src/app/app.component.css +75 -0 | @@ -0,0 +1,75 @@ | ||
| 1 | +:host { | |
| 2 | + display: block; | |
| 3 | +} | |
| 4 | + | |
| 5 | +.app-header { | |
| 6 | + position: sticky; | |
| 7 | + top: 0; | |
| 8 | + z-index: 10; | |
| 9 | + display: flex; | |
| 10 | + flex-wrap: wrap; | |
| 11 | + align-items: center; | |
| 12 | + justify-content: space-between; | |
| 13 | + gap: 0.5rem 1.5rem; | |
| 14 | + padding: 0.75rem var(--gutter); | |
| 15 | + background: color-mix(in srgb, var(--surface) 92%, transparent); | |
| 16 | + border-bottom: 1px solid var(--line); | |
| 17 | + backdrop-filter: blur(8px); | |
| 18 | +} | |
| 19 | + | |
| 20 | +.brand { | |
| 21 | + display: inline-flex; | |
| 22 | + align-items: center; | |
| 23 | + gap: 0.5rem; | |
| 24 | + color: var(--visited); | |
| 25 | + font-family: var(--font-display); | |
| 26 | + font-size: 1.35rem; | |
| 27 | + font-weight: 700; | |
| 28 | + text-decoration: none; | |
| 29 | +} | |
| 30 | + | |
| 31 | +nav { | |
| 32 | + display: flex; | |
| 33 | + flex-wrap: wrap; | |
| 34 | + gap: 0.25rem; | |
| 35 | +} | |
| 36 | + | |
| 37 | +nav a { | |
| 38 | + padding: 0.45rem 0.8rem; | |
| 39 | + border-radius: 999px; | |
| 40 | + color: var(--ink); | |
| 41 | + font-weight: 600; | |
| 42 | + text-decoration: none; | |
| 43 | +} | |
| 44 | + | |
| 45 | +nav a:hover { | |
| 46 | + background: var(--visited-soft); | |
| 47 | +} | |
| 48 | + | |
| 49 | +nav a.is-active { | |
| 50 | + background: var(--visited); | |
| 51 | + color: var(--on-accent); | |
| 52 | +} | |
| 53 | + | |
| 54 | +.count { | |
| 55 | + display: inline-block; | |
| 56 | + min-width: 1.4em; | |
| 57 | + margin-left: 0.2rem; | |
| 58 | + padding: 0 0.4em; | |
| 59 | + border-radius: 999px; | |
| 60 | + background: var(--wish-soft); | |
| 61 | + color: var(--wish); | |
| 62 | + font-size: 0.8rem; | |
| 63 | + text-align: center; | |
| 64 | +} | |
| 65 | + | |
| 66 | +main { | |
| 67 | + min-height: 70vh; | |
| 68 | +} | |
| 69 | + | |
| 70 | +.app-footer { | |
| 71 | + padding: 2rem var(--gutter); | |
| 72 | + color: var(--muted); | |
| 73 | + font-size: 0.85rem; | |
| 74 | + text-align: center; | |
| 75 | +} | |
| @@ -0,0 +1,75 @@ | |||
| 1 | +:host { | ||
| 2 | + display: block; | ||
| 3 | +} | ||
| 4 | + | ||
| 5 | +.app-header { | ||
| 6 | + position: sticky; | ||
| 7 | + top: 0; | ||
| 8 | + z-index: 10; | ||
| 9 | + display: flex; | ||
| 10 | + flex-wrap: wrap; | ||
| 11 | + align-items: center; | ||
| 12 | + justify-content: space-between; | ||
| 13 | + gap: 0.5rem 1.5rem; | ||
| 14 | + padding: 0.75rem var(--gutter); | ||
| 15 | + background: color-mix(in srgb, var(--surface) 92%, transparent); | ||
| 16 | + border-bottom: 1px solid var(--line); | ||
| 17 | + backdrop-filter: blur(8px); | ||
| 18 | +} | ||
| 19 | + | ||
| 20 | +.brand { | ||
| 21 | + display: inline-flex; | ||
| 22 | + align-items: center; | ||
| 23 | + gap: 0.5rem; | ||
| 24 | + color: var(--visited); | ||
| 25 | + font-family: var(--font-display); | ||
| 26 | + font-size: 1.35rem; | ||
| 27 | + font-weight: 700; | ||
| 28 | + text-decoration: none; | ||
| 29 | +} | ||
| 30 | + | ||
| 31 | +nav { | ||
| 32 | + display: flex; | ||
| 33 | + flex-wrap: wrap; | ||
| 34 | + gap: 0.25rem; | ||
| 35 | +} | ||
| 36 | + | ||
| 37 | +nav a { | ||
| 38 | + padding: 0.45rem 0.8rem; | ||
| 39 | + border-radius: 999px; | ||
| 40 | + color: var(--ink); | ||
| 41 | + font-weight: 600; | ||
| 42 | + text-decoration: none; | ||
| 43 | +} | ||
| 44 | + | ||
| 45 | +nav a:hover { | ||
| 46 | + background: var(--visited-soft); | ||
| 47 | +} | ||
| 48 | + | ||
| 49 | +nav a.is-active { | ||
| 50 | + background: var(--visited); | ||
| 51 | + color: var(--on-accent); | ||
| 52 | +} | ||
| 53 | + | ||
| 54 | +.count { | ||
| 55 | + display: inline-block; | ||
| 56 | + min-width: 1.4em; | ||
| 57 | + margin-left: 0.2rem; | ||
| 58 | + padding: 0 0.4em; | ||
| 59 | + border-radius: 999px; | ||
| 60 | + background: var(--wish-soft); | ||
| 61 | + color: var(--wish); | ||
| 62 | + font-size: 0.8rem; | ||
| 63 | + text-align: center; | ||
| 64 | +} | ||
| 65 | + | ||
| 66 | +main { | ||
| 67 | + min-height: 70vh; | ||
| 68 | +} | ||
| 69 | + | ||
| 70 | +.app-footer { | ||
| 71 | + padding: 2rem var(--gutter); | ||
| 72 | + color: var(--muted); | ||
| 73 | + font-size: 0.85rem; | ||
| 74 | + text-align: center; | ||
| 75 | +} | ||
modified
src/app/app.component.html +24 -334 | @@ -1,336 +1,26 @@ | ||
| 1 | -<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * --> | |
| 2 | -<!-- * * * * * * * * * * * The content below * * * * * * * * * * * --> | |
| 3 | -<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * --> | |
| 4 | -<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * --> | |
| 5 | -<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * --> | |
| 6 | -<!-- * * * * * * * * * Delete the template below * * * * * * * * * --> | |
| 7 | -<!-- * * * * * * * to get started with your project! * * * * * * * --> | |
| 8 | -<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * --> | |
| 9 | - | |
| 10 | -<style> | |
| 11 | - :host { | |
| 12 | - --bright-blue: oklch(51.01% 0.274 263.83); | |
| 13 | - --electric-violet: oklch(53.18% 0.28 296.97); | |
| 14 | - --french-violet: oklch(47.66% 0.246 305.88); | |
| 15 | - --vivid-pink: oklch(69.02% 0.277 332.77); | |
| 16 | - --hot-red: oklch(61.42% 0.238 15.34); | |
| 17 | - --orange-red: oklch(63.32% 0.24 31.68); | |
| 18 | - | |
| 19 | - --gray-900: oklch(19.37% 0.006 300.98); | |
| 20 | - --gray-700: oklch(36.98% 0.014 302.71); | |
| 21 | - --gray-400: oklch(70.9% 0.015 304.04); | |
| 22 | - | |
| 23 | - --red-to-pink-to-purple-vertical-gradient: linear-gradient( | |
| 24 | - 180deg, | |
| 25 | - var(--orange-red) 0%, | |
| 26 | - var(--vivid-pink) 50%, | |
| 27 | - var(--electric-violet) 100% | |
| 28 | - ); | |
| 29 | - | |
| 30 | - --red-to-pink-to-purple-horizontal-gradient: linear-gradient( | |
| 31 | - 90deg, | |
| 32 | - var(--orange-red) 0%, | |
| 33 | - var(--vivid-pink) 50%, | |
| 34 | - var(--electric-violet) 100% | |
| 35 | - ); | |
| 36 | - | |
| 37 | - --pill-accent: var(--bright-blue); | |
| 38 | - | |
| 39 | - font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, | |
| 40 | - Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", | |
| 41 | - "Segoe UI Symbol"; | |
| 42 | - box-sizing: border-box; | |
| 43 | - -webkit-font-smoothing: antialiased; | |
| 44 | - -moz-osx-font-smoothing: grayscale; | |
| 45 | - } | |
| 46 | - | |
| 47 | - h1 { | |
| 48 | - font-size: 3.125rem; | |
| 49 | - color: var(--gray-900); | |
| 50 | - font-weight: 500; | |
| 51 | - line-height: 100%; | |
| 52 | - letter-spacing: -0.125rem; | |
| 53 | - margin: 0; | |
| 54 | - font-family: "Inter Tight", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, | |
| 55 | - Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", | |
| 56 | - "Segoe UI Symbol"; | |
| 57 | - } | |
| 58 | - | |
| 59 | - p { | |
| 60 | - margin: 0; | |
| 61 | - color: var(--gray-700); | |
| 62 | - } | |
| 63 | - | |
| 64 | - main { | |
| 65 | - width: 100%; | |
| 66 | - min-height: 100%; | |
| 67 | - display: flex; | |
| 68 | - justify-content: center; | |
| 69 | - align-items: center; | |
| 70 | - padding: 1rem; | |
| 71 | - box-sizing: inherit; | |
| 72 | - position: relative; | |
| 73 | - } | |
| 74 | - | |
| 75 | - .angular-logo { | |
| 76 | - max-width: 9.2rem; | |
| 77 | - } | |
| 78 | - | |
| 79 | - .content { | |
| 80 | - display: flex; | |
| 81 | - justify-content: space-around; | |
| 82 | - width: 100%; | |
| 83 | - max-width: 700px; | |
| 84 | - margin-bottom: 3rem; | |
| 85 | - } | |
| 86 | - | |
| 87 | - .content h1 { | |
| 88 | - margin-top: 1.75rem; | |
| 89 | - } | |
| 90 | - | |
| 91 | - .content p { | |
| 92 | - margin-top: 1.5rem; | |
| 93 | - } | |
| 94 | - | |
| 95 | - .divider { | |
| 96 | - width: 1px; | |
| 97 | - background: var(--red-to-pink-to-purple-vertical-gradient); | |
| 98 | - margin-inline: 0.5rem; | |
| 99 | - } | |
| 100 | - | |
| 101 | - .pill-group { | |
| 102 | - display: flex; | |
| 103 | - flex-direction: column; | |
| 104 | - align-items: start; | |
| 105 | - flex-wrap: wrap; | |
| 106 | - gap: 1.25rem; | |
| 107 | - } | |
| 108 | - | |
| 109 | - .pill { | |
| 110 | - display: flex; | |
| 111 | - align-items: center; | |
| 112 | - --pill-accent: var(--bright-blue); | |
| 113 | - background: color-mix(in srgb, var(--pill-accent) 5%, transparent); | |
| 114 | - color: var(--pill-accent); | |
| 115 | - padding-inline: 0.75rem; | |
| 116 | - padding-block: 0.375rem; | |
| 117 | - border-radius: 2.75rem; | |
| 118 | - border: 0; | |
| 119 | - transition: background 0.3s ease; | |
| 120 | - font-family: var(--inter-font); | |
| 121 | - font-size: 0.875rem; | |
| 122 | - font-style: normal; | |
| 123 | - font-weight: 500; | |
| 124 | - line-height: 1.4rem; | |
| 125 | - letter-spacing: -0.00875rem; | |
| 126 | - text-decoration: none; | |
| 127 | - } | |
| 128 | - | |
| 129 | - .pill:hover { | |
| 130 | - background: color-mix(in srgb, var(--pill-accent) 15%, transparent); | |
| 131 | - } | |
| 132 | - | |
| 133 | - .pill-group .pill:nth-child(6n + 1) { | |
| 134 | - --pill-accent: var(--bright-blue); | |
| 135 | - } | |
| 136 | - .pill-group .pill:nth-child(6n + 2) { | |
| 137 | - --pill-accent: var(--french-violet); | |
| 138 | - } | |
| 139 | - .pill-group .pill:nth-child(6n + 3), | |
| 140 | - .pill-group .pill:nth-child(6n + 4), | |
| 141 | - .pill-group .pill:nth-child(6n + 5) { | |
| 142 | - --pill-accent: var(--hot-red); | |
| 143 | - } | |
| 144 | - | |
| 145 | - .pill-group svg { | |
| 146 | - margin-inline-start: 0.25rem; | |
| 147 | - } | |
| 148 | - | |
| 149 | - .social-links { | |
| 150 | - display: flex; | |
| 151 | - align-items: center; | |
| 152 | - gap: 0.73rem; | |
| 153 | - margin-top: 1.5rem; | |
| 154 | - } | |
| 155 | - | |
| 156 | - .social-links path { | |
| 157 | - transition: fill 0.3s ease; | |
| 158 | - fill: var(--gray-400); | |
| 159 | - } | |
| 160 | - | |
| 161 | - .social-links a:hover svg path { | |
| 162 | - fill: var(--gray-900); | |
| 163 | - } | |
| 164 | - | |
| 165 | - @media screen and (max-width: 650px) { | |
| 166 | - .content { | |
| 167 | - flex-direction: column; | |
| 168 | - width: max-content; | |
| 169 | - } | |
| 170 | - | |
| 171 | - .divider { | |
| 172 | - height: 1px; | |
| 173 | - width: 100%; | |
| 174 | - background: var(--red-to-pink-to-purple-horizontal-gradient); | |
| 175 | - margin-block: 1.5rem; | |
| 176 | - } | |
| 177 | - } | |
| 178 | -</style> | |
| 179 | - | |
| 180 | -<main class="main"> | |
| 181 | - <div class="content"> | |
| 182 | - <div class="left-side"> | |
| 183 | - <svg | |
| 184 | - xmlns="http://www.w3.org/2000/svg" | |
| 185 | - viewBox="0 0 982 239" | |
| 186 | - fill="none" | |
| 187 | - class="angular-logo" | |
| 188 | - > | |
| 189 | - <g clip-path="url(#a)"> | |
| 190 | - <path | |
| 191 | - fill="url(#b)" | |
| 192 | - d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z" | |
| 193 | - /> | |
| 194 | - <path | |
| 195 | - fill="url(#c)" | |
| 196 | - d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z" | |
| 197 | - /> | |
| 198 | - </g> | |
| 199 | - <defs> | |
| 200 | - <radialGradient | |
| 201 | - id="c" | |
| 202 | - cx="0" | |
| 203 | - cy="0" | |
| 204 | - r="1" | |
| 205 | - gradientTransform="rotate(118.122 171.182 60.81) scale(205.794)" | |
| 206 | - gradientUnits="userSpaceOnUse" | |
| 207 | - > | |
| 208 | - <stop stop-color="#FF41F8" /> | |
| 209 | - <stop offset=".707" stop-color="#FF41F8" stop-opacity=".5" /> | |
| 210 | - <stop offset="1" stop-color="#FF41F8" stop-opacity="0" /> | |
| 211 | - </radialGradient> | |
| 212 | - <linearGradient | |
| 213 | - id="b" | |
| 214 | - x1="0" | |
| 215 | - x2="982" | |
| 216 | - y1="192" | |
| 217 | - y2="192" | |
| 218 | - gradientUnits="userSpaceOnUse" | |
| 219 | - > | |
| 220 | - <stop stop-color="#F0060B" /> | |
| 221 | - <stop offset="0" stop-color="#F0070C" /> | |
| 222 | - <stop offset=".526" stop-color="#CC26D5" /> | |
| 223 | - <stop offset="1" stop-color="#7702FF" /> | |
| 224 | - </linearGradient> | |
| 225 | - <clipPath id="a"><path fill="#fff" d="M0 0h982v239H0z" /></clipPath> | |
| 226 | - </defs> | |
| 227 | - </svg> | |
| 228 | - <h1>Hello, {{ title }}</h1> | |
| 229 | - <p>Congratulations! Your app is running. 🎉</p> | |
| 230 | - </div> | |
| 231 | - <div class="divider" role="separator" aria-label="Divider"></div> | |
| 232 | - <div class="right-side"> | |
| 233 | - <div class="pill-group"> | |
| 234 | - @for (item of [ | |
| 235 | - { title: 'Explore the Docs', link: 'https://angular.dev' }, | |
| 236 | - { title: 'Learn with Tutorials', link: 'https://angular.dev/tutorials' }, | |
| 237 | - { title: 'CLI Docs', link: 'https://angular.dev/tools/cli' }, | |
| 238 | - { title: 'Angular Language Service', link: 'https://angular.dev/tools/language-service' }, | |
| 239 | - { title: 'Angular DevTools', link: 'https://angular.dev/tools/devtools' }, | |
| 240 | - ]; track item.title) { | |
| 241 | - <a | |
| 242 | - class="pill" | |
| 243 | - [href]="item.link" | |
| 244 | - target="_blank" | |
| 245 | - rel="noopener" | |
| 246 | - > | |
| 247 | - <span>{{ item.title }}</span> | |
| 248 | - <svg | |
| 249 | - xmlns="http://www.w3.org/2000/svg" | |
| 250 | - height="14" | |
| 251 | - viewBox="0 -960 960 960" | |
| 252 | - width="14" | |
| 253 | - fill="currentColor" | |
| 254 | - > | |
| 255 | - <path | |
| 256 | - d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z" | |
| 257 | - /> | |
| 258 | - </svg> | |
| 259 | - </a> | |
| 260 | - } | |
| 261 | - </div> | |
| 262 | - <div class="social-links"> | |
| 263 | - <a | |
| 264 | - href="https://github.com/angular/angular" | |
| 265 | - aria-label="Github" | |
| 266 | - target="_blank" | |
| 267 | - rel="noopener" | |
| 268 | - > | |
| 269 | - <svg | |
| 270 | - width="25" | |
| 271 | - height="24" | |
| 272 | - viewBox="0 0 25 24" | |
| 273 | - fill="none" | |
| 274 | - xmlns="http://www.w3.org/2000/svg" | |
| 275 | - alt="Github" | |
| 276 | - > | |
| 277 | - <path | |
| 278 | - d="M12.3047 0C5.50634 0 0 5.50942 0 12.3047C0 17.7423 3.52529 22.3535 8.41332 23.9787C9.02856 24.0946 9.25414 23.7142 9.25414 23.3871C9.25414 23.0949 9.24389 22.3207 9.23876 21.2953C5.81601 22.0377 5.09414 19.6444 5.09414 19.6444C4.53427 18.2243 3.72524 17.8449 3.72524 17.8449C2.61064 17.082 3.81137 17.0973 3.81137 17.0973C5.04697 17.1835 5.69604 18.3647 5.69604 18.3647C6.79321 20.2463 8.57636 19.7029 9.27978 19.3881C9.39052 18.5924 9.70736 18.0499 10.0591 17.7423C7.32641 17.4347 4.45429 16.3765 4.45429 11.6618C4.45429 10.3185 4.9311 9.22133 5.72065 8.36C5.58222 8.04931 5.16694 6.79833 5.82831 5.10337C5.82831 5.10337 6.85883 4.77319 9.2121 6.36459C10.1965 6.09082 11.2424 5.95546 12.2883 5.94931C13.3342 5.95546 14.3801 6.09082 15.3644 6.36459C17.7023 4.77319 18.7328 5.10337 18.7328 5.10337C19.3942 6.79833 18.9789 8.04931 18.8559 8.36C19.6403 9.22133 20.1171 10.3185 20.1171 11.6618C20.1171 16.3888 17.2409 17.4296 14.5031 17.7321C14.9338 18.1012 15.3337 18.8559 15.3337 20.0084C15.3337 21.6552 15.3183 22.978 15.3183 23.3779C15.3183 23.7009 15.5336 24.0854 16.1642 23.9623C21.0871 22.3484 24.6094 17.7341 24.6094 12.3047C24.6094 5.50942 19.0999 0 12.3047 0Z" | |
| 279 | - /> | |
| 280 | - </svg> | |
| 281 | - </a> | |
| 282 | - <a | |
| 283 | - href="https://twitter.com/angular" | |
| 284 | - aria-label="Twitter" | |
| 285 | - target="_blank" | |
| 286 | - rel="noopener" | |
| 287 | - > | |
| 288 | - <svg | |
| 289 | - width="24" | |
| 290 | - height="24" | |
| 291 | - viewBox="0 0 24 24" | |
| 292 | - fill="none" | |
| 293 | - xmlns="http://www.w3.org/2000/svg" | |
| 294 | - alt="Twitter" | |
| 295 | - > | |
| 296 | - <path | |
| 297 | - d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" | |
| 298 | - /> | |
| 299 | - </svg> | |
| 300 | - </a> | |
| 301 | - <a | |
| 302 | - href="https://www.youtube.com/channel/UCbn1OgGei-DV7aSRo_HaAiw" | |
| 303 | - aria-label="Youtube" | |
| 304 | - target="_blank" | |
| 305 | - rel="noopener" | |
| 306 | - > | |
| 307 | - <svg | |
| 308 | - width="29" | |
| 309 | - height="20" | |
| 310 | - viewBox="0 0 29 20" | |
| 311 | - fill="none" | |
| 312 | - xmlns="http://www.w3.org/2000/svg" | |
| 313 | - alt="Youtube" | |
| 314 | - > | |
| 315 | - <path | |
| 316 | - fill-rule="evenodd" | |
| 317 | - clip-rule="evenodd" | |
| 318 | - d="M27.4896 1.52422C27.9301 1.96749 28.2463 2.51866 28.4068 3.12258C29.0004 5.35161 29.0004 10 29.0004 10C29.0004 10 29.0004 14.6484 28.4068 16.8774C28.2463 17.4813 27.9301 18.0325 27.4896 18.4758C27.0492 18.9191 26.5 19.2389 25.8972 19.4032C23.6778 20 14.8068 20 14.8068 20C14.8068 20 5.93586 20 3.71651 19.4032C3.11363 19.2389 2.56449 18.9191 2.12405 18.4758C1.68361 18.0325 1.36732 17.4813 1.20683 16.8774C0.613281 14.6484 0.613281 10 0.613281 10C0.613281 10 0.613281 5.35161 1.20683 3.12258C1.36732 2.51866 1.68361 1.96749 2.12405 1.52422C2.56449 1.08095 3.11363 0.76113 3.71651 0.596774C5.93586 0 14.8068 0 14.8068 0C14.8068 0 23.6778 0 25.8972 0.596774C26.5 0.76113 27.0492 1.08095 27.4896 1.52422ZM19.3229 10L11.9036 5.77905V14.221L19.3229 10Z" | |
| 319 | - /> | |
| 320 | - </svg> | |
| 321 | - </a> | |
| 322 | - </div> | |
| 323 | - </div> | |
| 324 | - </div> | |
| 1 | +<header class="app-header"> | |
| 2 | + <a class="brand" routerLink="/resume"> | |
| 3 | + <svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true" focusable="false"> | |
| 4 | + <path d="M4 20c0-8 8-6 8-12s6-4 8-4" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" /> | |
| 5 | + <circle cx="4" cy="20" r="2" fill="currentColor" /> | |
| 6 | + <circle cx="20" cy="4" r="2" fill="currentColor" /> | |
| 7 | + </svg> | |
| 8 | + {{ title }} | |
| 9 | + </a> | |
| 10 | + | |
| 11 | + <nav aria-label="Navigation principale"> | |
| 12 | + <a routerLink="/resume" routerLinkActive="is-active">Résumé</a> | |
| 13 | + <a routerLink="/souhaits" routerLinkActive="is-active"> | |
| 14 | + Souhaits <span class="count">{{ store.stats().wishlistCount }}</span> | |
| 15 | + </a> | |
| 16 | + <a routerLink="/lieux/nouveau" routerLinkActive="is-active">Ajouter un lieu</a> | |
| 17 | + </nav> | |
| 18 | +</header> | |
| 19 | + | |
| 20 | +<main> | |
| 21 | + <router-outlet></router-outlet> | |
| 325 | 22 | </main> |
| 326 | 23 | |
| 327 | -<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * --> | |
| 328 | -<!-- * * * * * * * * * * * The content above * * * * * * * * * * * * --> | |
| 329 | -<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * * --> | |
| 330 | -<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * * --> | |
| 331 | -<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * --> | |
| 332 | -<!-- * * * * * * * * * * End of Placeholder * * * * * * * * * * * * --> | |
| 333 | -<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * --> | |
| 334 | - | |
| 335 | - | |
| 336 | -<router-outlet /> | |
| 24 | +<footer class="app-footer"> | |
| 25 | + Vos lieux sont enregistrés uniquement dans ce navigateur. Fonds de carte © contributeurs OpenStreetMap. | |
| 26 | +</footer> | |
| @@ -1,336 +1,26 @@ | |||
| 1 | -<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * --> | 1 | +<header class="app-header"> |
| 2 | -<!-- * * * * * * * * * * * The content below * * * * * * * * * * * --> | 2 | + <a class="brand" routerLink="/resume"> |
| 3 | -<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * --> | 3 | + <svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true" focusable="false"> |
| 4 | -<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * --> | 4 | + <path d="M4 20c0-8 8-6 8-12s6-4 8-4" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" /> |
| 5 | -<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * --> | 5 | + <circle cx="4" cy="20" r="2" fill="currentColor" /> |
| 6 | -<!-- * * * * * * * * * Delete the template below * * * * * * * * * --> | 6 | + <circle cx="20" cy="4" r="2" fill="currentColor" /> |
| 7 | -<!-- * * * * * * * to get started with your project! * * * * * * * --> | 7 | + </svg> |
| 8 | -<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * --> | 8 | + {{ title }} |
| 9 | - | 9 | + </a> |
| 10 | -<style> | 10 | + |
| 11 | - :host { | 11 | + <nav aria-label="Navigation principale"> |
| 12 | - --bright-blue: oklch(51.01% 0.274 263.83); | 12 | + <a routerLink="/resume" routerLinkActive="is-active">Résumé</a> |
| 13 | - --electric-violet: oklch(53.18% 0.28 296.97); | 13 | + <a routerLink="/souhaits" routerLinkActive="is-active"> |
| 14 | - --french-violet: oklch(47.66% 0.246 305.88); | 14 | + Souhaits <span class="count">{{ store.stats().wishlistCount }}</span> |
| 15 | - --vivid-pink: oklch(69.02% 0.277 332.77); | 15 | + </a> |
| 16 | - --hot-red: oklch(61.42% 0.238 15.34); | 16 | + <a routerLink="/lieux/nouveau" routerLinkActive="is-active">Ajouter un lieu</a> |
| 17 | - --orange-red: oklch(63.32% 0.24 31.68); | 17 | + </nav> |
| 18 | - | 18 | +</header> |
| 19 | - --gray-900: oklch(19.37% 0.006 300.98); | 19 | + |
| 20 | - --gray-700: oklch(36.98% 0.014 302.71); | 20 | +<main> |
| 21 | - --gray-400: oklch(70.9% 0.015 304.04); | 21 | + <router-outlet></router-outlet> |
| 22 | - | ||
| 23 | - --red-to-pink-to-purple-vertical-gradient: linear-gradient( | ||
| 24 | - 180deg, | ||
| 25 | - var(--orange-red) 0%, | ||
| 26 | - var(--vivid-pink) 50%, | ||
| 27 | - var(--electric-violet) 100% | ||
| 28 | - ); | ||
| 29 | - | ||
| 30 | - --red-to-pink-to-purple-horizontal-gradient: linear-gradient( | ||
| 31 | - 90deg, | ||
| 32 | - var(--orange-red) 0%, | ||
| 33 | - var(--vivid-pink) 50%, | ||
| 34 | - var(--electric-violet) 100% | ||
| 35 | - ); | ||
| 36 | - | ||
| 37 | - --pill-accent: var(--bright-blue); | ||
| 38 | - | ||
| 39 | - font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, | ||
| 40 | - Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", | ||
| 41 | - "Segoe UI Symbol"; | ||
| 42 | - box-sizing: border-box; | ||
| 43 | - -webkit-font-smoothing: antialiased; | ||
| 44 | - -moz-osx-font-smoothing: grayscale; | ||
| 45 | - } | ||
| 46 | - | ||
| 47 | - h1 { | ||
| 48 | - font-size: 3.125rem; | ||
| 49 | - color: var(--gray-900); | ||
| 50 | - font-weight: 500; | ||
| 51 | - line-height: 100%; | ||
| 52 | - letter-spacing: -0.125rem; | ||
| 53 | - margin: 0; | ||
| 54 | - font-family: "Inter Tight", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, | ||
| 55 | - Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", | ||
| 56 | - "Segoe UI Symbol"; | ||
| 57 | - } | ||
| 58 | - | ||
| 59 | - p { | ||
| 60 | - margin: 0; | ||
| 61 | - color: var(--gray-700); | ||
| 62 | - } | ||
| 63 | - | ||
| 64 | - main { | ||
| 65 | - width: 100%; | ||
| 66 | - min-height: 100%; | ||
| 67 | - display: flex; | ||
| 68 | - justify-content: center; | ||
| 69 | - align-items: center; | ||
| 70 | - padding: 1rem; | ||
| 71 | - box-sizing: inherit; | ||
| 72 | - position: relative; | ||
| 73 | - } | ||
| 74 | - | ||
| 75 | - .angular-logo { | ||
| 76 | - max-width: 9.2rem; | ||
| 77 | - } | ||
| 78 | - | ||
| 79 | - .content { | ||
| 80 | - display: flex; | ||
| 81 | - justify-content: space-around; | ||
| 82 | - width: 100%; | ||
| 83 | - max-width: 700px; | ||
| 84 | - margin-bottom: 3rem; | ||
| 85 | - } | ||
| 86 | - | ||
| 87 | - .content h1 { | ||
| 88 | - margin-top: 1.75rem; | ||
| 89 | - } | ||
| 90 | - | ||
| 91 | - .content p { | ||
| 92 | - margin-top: 1.5rem; | ||
| 93 | - } | ||
| 94 | - | ||
| 95 | - .divider { | ||
| 96 | - width: 1px; | ||
| 97 | - background: var(--red-to-pink-to-purple-vertical-gradient); | ||
| 98 | - margin-inline: 0.5rem; | ||
| 99 | - } | ||
| 100 | - | ||
| 101 | - .pill-group { | ||
| 102 | - display: flex; | ||
| 103 | - flex-direction: column; | ||
| 104 | - align-items: start; | ||
| 105 | - flex-wrap: wrap; | ||
| 106 | - gap: 1.25rem; | ||
| 107 | - } | ||
| 108 | - | ||
| 109 | - .pill { | ||
| 110 | - display: flex; | ||
| 111 | - align-items: center; | ||
| 112 | - --pill-accent: var(--bright-blue); | ||
| 113 | - background: color-mix(in srgb, var(--pill-accent) 5%, transparent); | ||
| 114 | - color: var(--pill-accent); | ||
| 115 | - padding-inline: 0.75rem; | ||
| 116 | - padding-block: 0.375rem; | ||
| 117 | - border-radius: 2.75rem; | ||
| 118 | - border: 0; | ||
| 119 | - transition: background 0.3s ease; | ||
| 120 | - font-family: var(--inter-font); | ||
| 121 | - font-size: 0.875rem; | ||
| 122 | - font-style: normal; | ||
| 123 | - font-weight: 500; | ||
| 124 | - line-height: 1.4rem; | ||
| 125 | - letter-spacing: -0.00875rem; | ||
| 126 | - text-decoration: none; | ||
| 127 | - } | ||
| 128 | - | ||
| 129 | - .pill:hover { | ||
| 130 | - background: color-mix(in srgb, var(--pill-accent) 15%, transparent); | ||
| 131 | - } | ||
| 132 | - | ||
| 133 | - .pill-group .pill:nth-child(6n + 1) { | ||
| 134 | - --pill-accent: var(--bright-blue); | ||
| 135 | - } | ||
| 136 | - .pill-group .pill:nth-child(6n + 2) { | ||
| 137 | - --pill-accent: var(--french-violet); | ||
| 138 | - } | ||
| 139 | - .pill-group .pill:nth-child(6n + 3), | ||
| 140 | - .pill-group .pill:nth-child(6n + 4), | ||
| 141 | - .pill-group .pill:nth-child(6n + 5) { | ||
| 142 | - --pill-accent: var(--hot-red); | ||
| 143 | - } | ||
| 144 | - | ||
| 145 | - .pill-group svg { | ||
| 146 | - margin-inline-start: 0.25rem; | ||
| 147 | - } | ||
| 148 | - | ||
| 149 | - .social-links { | ||
| 150 | - display: flex; | ||
| 151 | - align-items: center; | ||
| 152 | - gap: 0.73rem; | ||
| 153 | - margin-top: 1.5rem; | ||
| 154 | - } | ||
| 155 | - | ||
| 156 | - .social-links path { | ||
| 157 | - transition: fill 0.3s ease; | ||
| 158 | - fill: var(--gray-400); | ||
| 159 | - } | ||
| 160 | - | ||
| 161 | - .social-links a:hover svg path { | ||
| 162 | - fill: var(--gray-900); | ||
| 163 | - } | ||
| 164 | - | ||
| 165 | - @media screen and (max-width: 650px) { | ||
| 166 | - .content { | ||
| 167 | - flex-direction: column; | ||
| 168 | - width: max-content; | ||
| 169 | - } | ||
| 170 | - | ||
| 171 | - .divider { | ||
| 172 | - height: 1px; | ||
| 173 | - width: 100%; | ||
| 174 | - background: var(--red-to-pink-to-purple-horizontal-gradient); | ||
| 175 | - margin-block: 1.5rem; | ||
| 176 | - } | ||
| 177 | - } | ||
| 178 | -</style> | ||
| 179 | - | ||
| 180 | -<main class="main"> | ||
| 181 | - <div class="content"> | ||
| 182 | - <div class="left-side"> | ||
| 183 | - <svg | ||
| 184 | - xmlns="http://www.w3.org/2000/svg" | ||
| 185 | - viewBox="0 0 982 239" | ||
| 186 | - fill="none" | ||
| 187 | - class="angular-logo" | ||
| 188 | - > | ||
| 189 | - <g clip-path="url(#a)"> | ||
| 190 | - <path | ||
| 191 | - fill="url(#b)" | ||
| 192 | - d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z" | ||
| 193 | - /> | ||
| 194 | - <path | ||
| 195 | - fill="url(#c)" | ||
| 196 | - d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z" | ||
| 197 | - /> | ||
| 198 | - </g> | ||
| 199 | - <defs> | ||
| 200 | - <radialGradient | ||
| 201 | - id="c" | ||
| 202 | - cx="0" | ||
| 203 | - cy="0" | ||
| 204 | - r="1" | ||
| 205 | - gradientTransform="rotate(118.122 171.182 60.81) scale(205.794)" | ||
| 206 | - gradientUnits="userSpaceOnUse" | ||
| 207 | - > | ||
| 208 | - <stop stop-color="#FF41F8" /> | ||
| 209 | - <stop offset=".707" stop-color="#FF41F8" stop-opacity=".5" /> | ||
| 210 | - <stop offset="1" stop-color="#FF41F8" stop-opacity="0" /> | ||
| 211 | - </radialGradient> | ||
| 212 | - <linearGradient | ||
| 213 | - id="b" | ||
| 214 | - x1="0" | ||
| 215 | - x2="982" | ||
| 216 | - y1="192" | ||
| 217 | - y2="192" | ||
| 218 | - gradientUnits="userSpaceOnUse" | ||
| 219 | - > | ||
| 220 | - <stop stop-color="#F0060B" /> | ||
| 221 | - <stop offset="0" stop-color="#F0070C" /> | ||
| 222 | - <stop offset=".526" stop-color="#CC26D5" /> | ||
| 223 | - <stop offset="1" stop-color="#7702FF" /> | ||
| 224 | - </linearGradient> | ||
| 225 | - <clipPath id="a"><path fill="#fff" d="M0 0h982v239H0z" /></clipPath> | ||
| 226 | - </defs> | ||
| 227 | - </svg> | ||
| 228 | - <h1>Hello, {{ title }}</h1> | ||
| 229 | - <p>Congratulations! Your app is running. 🎉</p> | ||
| 230 | - </div> | ||
| 231 | - <div class="divider" role="separator" aria-label="Divider"></div> | ||
| 232 | - <div class="right-side"> | ||
| 233 | - <div class="pill-group"> | ||
| 234 | - @for (item of [ | ||
| 235 | - { title: 'Explore the Docs', link: 'https://angular.dev' }, | ||
| 236 | - { title: 'Learn with Tutorials', link: 'https://angular.dev/tutorials' }, | ||
| 237 | - { title: 'CLI Docs', link: 'https://angular.dev/tools/cli' }, | ||
| 238 | - { title: 'Angular Language Service', link: 'https://angular.dev/tools/language-service' }, | ||
| 239 | - { title: 'Angular DevTools', link: 'https://angular.dev/tools/devtools' }, | ||
| 240 | - ]; track item.title) { | ||
| 241 | - <a | ||
| 242 | - class="pill" | ||
| 243 | - [href]="item.link" | ||
| 244 | - target="_blank" | ||
| 245 | - rel="noopener" | ||
| 246 | - > | ||
| 247 | - <span>{{ item.title }}</span> | ||
| 248 | - <svg | ||
| 249 | - xmlns="http://www.w3.org/2000/svg" | ||
| 250 | - height="14" | ||
| 251 | - viewBox="0 -960 960 960" | ||
| 252 | - width="14" | ||
| 253 | - fill="currentColor" | ||
| 254 | - > | ||
| 255 | - <path | ||
| 256 | - d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z" | ||
| 257 | - /> | ||
| 258 | - </svg> | ||
| 259 | - </a> | ||
| 260 | - } | ||
| 261 | - </div> | ||
| 262 | - <div class="social-links"> | ||
| 263 | - <a | ||
| 264 | - href="https://github.com/angular/angular" | ||
| 265 | - aria-label="Github" | ||
| 266 | - target="_blank" | ||
| 267 | - rel="noopener" | ||
| 268 | - > | ||
| 269 | - <svg | ||
| 270 | - width="25" | ||
| 271 | - height="24" | ||
| 272 | - viewBox="0 0 25 24" | ||
| 273 | - fill="none" | ||
| 274 | - xmlns="http://www.w3.org/2000/svg" | ||
| 275 | - alt="Github" | ||
| 276 | - > | ||
| 277 | - <path | ||
| 278 | - d="M12.3047 0C5.50634 0 0 5.50942 0 12.3047C0 17.7423 3.52529 22.3535 8.41332 23.9787C9.02856 24.0946 9.25414 23.7142 9.25414 23.3871C9.25414 23.0949 9.24389 22.3207 9.23876 21.2953C5.81601 22.0377 5.09414 19.6444 5.09414 19.6444C4.53427 18.2243 3.72524 17.8449 3.72524 17.8449C2.61064 17.082 3.81137 17.0973 3.81137 17.0973C5.04697 17.1835 5.69604 18.3647 5.69604 18.3647C6.79321 20.2463 8.57636 19.7029 9.27978 19.3881C9.39052 18.5924 9.70736 18.0499 10.0591 17.7423C7.32641 17.4347 4.45429 16.3765 4.45429 11.6618C4.45429 10.3185 4.9311 9.22133 5.72065 8.36C5.58222 8.04931 5.16694 6.79833 5.82831 5.10337C5.82831 5.10337 6.85883 4.77319 9.2121 6.36459C10.1965 6.09082 11.2424 5.95546 12.2883 5.94931C13.3342 5.95546 14.3801 6.09082 15.3644 6.36459C17.7023 4.77319 18.7328 5.10337 18.7328 5.10337C19.3942 6.79833 18.9789 8.04931 18.8559 8.36C19.6403 9.22133 20.1171 10.3185 20.1171 11.6618C20.1171 16.3888 17.2409 17.4296 14.5031 17.7321C14.9338 18.1012 15.3337 18.8559 15.3337 20.0084C15.3337 21.6552 15.3183 22.978 15.3183 23.3779C15.3183 23.7009 15.5336 24.0854 16.1642 23.9623C21.0871 22.3484 24.6094 17.7341 24.6094 12.3047C24.6094 5.50942 19.0999 0 12.3047 0Z" | ||
| 279 | - /> | ||
| 280 | - </svg> | ||
| 281 | - </a> | ||
| 282 | - <a | ||
| 283 | - href="https://twitter.com/angular" | ||
| 284 | - aria-label="Twitter" | ||
| 285 | - target="_blank" | ||
| 286 | - rel="noopener" | ||
| 287 | - > | ||
| 288 | - <svg | ||
| 289 | - width="24" | ||
| 290 | - height="24" | ||
| 291 | - viewBox="0 0 24 24" | ||
| 292 | - fill="none" | ||
| 293 | - xmlns="http://www.w3.org/2000/svg" | ||
| 294 | - alt="Twitter" | ||
| 295 | - > | ||
| 296 | - <path | ||
| 297 | - d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" | ||
| 298 | - /> | ||
| 299 | - </svg> | ||
| 300 | - </a> | ||
| 301 | - <a | ||
| 302 | - href="https://www.youtube.com/channel/UCbn1OgGei-DV7aSRo_HaAiw" | ||
| 303 | - aria-label="Youtube" | ||
| 304 | - target="_blank" | ||
| 305 | - rel="noopener" | ||
| 306 | - > | ||
| 307 | - <svg | ||
| 308 | - width="29" | ||
| 309 | - height="20" | ||
| 310 | - viewBox="0 0 29 20" | ||
| 311 | - fill="none" | ||
| 312 | - xmlns="http://www.w3.org/2000/svg" | ||
| 313 | - alt="Youtube" | ||
| 314 | - > | ||
| 315 | - <path | ||
| 316 | - fill-rule="evenodd" | ||
| 317 | - clip-rule="evenodd" | ||
| 318 | - d="M27.4896 1.52422C27.9301 1.96749 28.2463 2.51866 28.4068 3.12258C29.0004 5.35161 29.0004 10 29.0004 10C29.0004 10 29.0004 14.6484 28.4068 16.8774C28.2463 17.4813 27.9301 18.0325 27.4896 18.4758C27.0492 18.9191 26.5 19.2389 25.8972 19.4032C23.6778 20 14.8068 20 14.8068 20C14.8068 20 5.93586 20 3.71651 19.4032C3.11363 19.2389 2.56449 18.9191 2.12405 18.4758C1.68361 18.0325 1.36732 17.4813 1.20683 16.8774C0.613281 14.6484 0.613281 10 0.613281 10C0.613281 10 0.613281 5.35161 1.20683 3.12258C1.36732 2.51866 1.68361 1.96749 2.12405 1.52422C2.56449 1.08095 3.11363 0.76113 3.71651 0.596774C5.93586 0 14.8068 0 14.8068 0C14.8068 0 23.6778 0 25.8972 0.596774C26.5 0.76113 27.0492 1.08095 27.4896 1.52422ZM19.3229 10L11.9036 5.77905V14.221L19.3229 10Z" | ||
| 319 | - /> | ||
| 320 | - </svg> | ||
| 321 | - </a> | ||
| 322 | - </div> | ||
| 323 | - </div> | ||
| 324 | - </div> | ||
| 325 | </main> | 22 | </main> |
| 326 | 23 | ||
| 327 | -<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * --> | 24 | +<footer class="app-footer"> |
| 328 | -<!-- * * * * * * * * * * * The content above * * * * * * * * * * * * --> | 25 | + Vos lieux sont enregistrés uniquement dans ce navigateur. Fonds de carte © contributeurs OpenStreetMap. |
| 329 | -<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * * --> | 26 | +</footer> |
| 330 | -<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * * --> | ||
| 331 | -<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * --> | ||
| 332 | -<!-- * * * * * * * * * * End of Placeholder * * * * * * * * * * * * --> | ||
| 333 | -<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * --> | ||
| 334 | - | ||
| 335 | - | ||
| 336 | -<router-outlet /> | ||
modified
src/app/app.component.spec.ts +20 -8 | @@ -1,29 +1,41 @@ | ||
| 1 | 1 | import { TestBed } from '@angular/core/testing'; |
| 2 | +import { provideRouter } from '@angular/router'; | |
| 2 | 3 | import { AppComponent } from './app.component'; |
| 4 | +import { memoryStorage } from './travel/testing/memory-storage'; | |
| 5 | +import { TRAVEL_STORAGE } from './travel/services/travel-store.service'; | |
| 3 | 6 | |
| 4 | 7 | describe('AppComponent', () => { |
| 5 | 8 | beforeEach(async () => { |
| 6 | 9 | await TestBed.configureTestingModule({ |
| 7 | 10 | imports: [AppComponent], |
| 11 | + providers: [provideRouter([]), { provide: TRAVEL_STORAGE, useValue: memoryStorage() }], | |
| 8 | 12 | }).compileComponents(); |
| 9 | 13 | }); |
| 10 | 14 | |
| 11 | 15 | it('should create the app', () => { |
| 12 | 16 | const fixture = TestBed.createComponent(AppComponent); |
| 13 | - const app = fixture.componentInstance; | |
| 14 | - expect(app).toBeTruthy(); | |
| 17 | + expect(fixture.componentInstance).toBeTruthy(); | |
| 15 | 18 | }); |
| 16 | 19 | |
| 17 | - it(`should have the 'roadTrip' title`, () => { | |
| 20 | + it(`should have the 'RoadTrip' title`, () => { | |
| 18 | 21 | const fixture = TestBed.createComponent(AppComponent); |
| 19 | - const app = fixture.componentInstance; | |
| 20 | - expect(app.title).toEqual('roadTrip'); | |
| 22 | + expect(fixture.componentInstance.title).toEqual('RoadTrip'); | |
| 21 | 23 | }); |
| 22 | 24 | |
| 23 | - it('should render title', () => { | |
| 25 | + it('affiche la marque et la navigation principale', () => { | |
| 24 | 26 | const fixture = TestBed.createComponent(AppComponent); |
| 25 | 27 | fixture.detectChanges(); |
| 26 | - const compiled = fixture.nativeElement as HTMLElement; | |
| 27 | - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, roadTrip'); | |
| 28 | + const root = fixture.nativeElement as HTMLElement; | |
| 29 | + | |
| 30 | + expect(root.querySelector('.brand')?.textContent).toContain('RoadTrip'); | |
| 31 | + const links = Array.from(root.querySelectorAll('nav a')).map((link) => link.getAttribute('href')); | |
| 32 | + expect(links).toEqual(['/resume', '/souhaits', '/lieux/nouveau']); | |
| 33 | + }); | |
| 34 | + | |
| 35 | + it('affiche le nombre de destinations dans la liste de souhaits', () => { | |
| 36 | + const fixture = TestBed.createComponent(AppComponent); | |
| 37 | + fixture.detectChanges(); | |
| 38 | + | |
| 39 | + expect((fixture.nativeElement as HTMLElement).querySelector('.count')?.textContent?.trim()).toBe('5'); | |
| 28 | 40 | }); |
| 29 | 41 | }); |
| @@ -1,29 +1,41 @@ | |||
| 1 | import { TestBed } from '@angular/core/testing'; | 1 | import { TestBed } from '@angular/core/testing'; |
| 2 | +import { provideRouter } from '@angular/router'; | ||
| 2 | import { AppComponent } from './app.component'; | 3 | import { AppComponent } from './app.component'; |
| 4 | +import { memoryStorage } from './travel/testing/memory-storage'; | ||
| 5 | +import { TRAVEL_STORAGE } from './travel/services/travel-store.service'; | ||
| 3 | 6 | ||
| 4 | describe('AppComponent', () => { | 7 | describe('AppComponent', () => { |
| 5 | beforeEach(async () => { | 8 | beforeEach(async () => { |
| 6 | await TestBed.configureTestingModule({ | 9 | await TestBed.configureTestingModule({ |
| 7 | imports: [AppComponent], | 10 | imports: [AppComponent], |
| 11 | + providers: [provideRouter([]), { provide: TRAVEL_STORAGE, useValue: memoryStorage() }], | ||
| 8 | }).compileComponents(); | 12 | }).compileComponents(); |
| 9 | }); | 13 | }); |
| 10 | 14 | ||
| 11 | it('should create the app', () => { | 15 | it('should create the app', () => { |
| 12 | const fixture = TestBed.createComponent(AppComponent); | 16 | const fixture = TestBed.createComponent(AppComponent); |
| 13 | - const app = fixture.componentInstance; | 17 | + expect(fixture.componentInstance).toBeTruthy(); |
| 14 | - expect(app).toBeTruthy(); | ||
| 15 | }); | 18 | }); |
| 16 | 19 | ||
| 17 | - it(`should have the 'roadTrip' title`, () => { | 20 | + it(`should have the 'RoadTrip' title`, () => { |
| 18 | const fixture = TestBed.createComponent(AppComponent); | 21 | const fixture = TestBed.createComponent(AppComponent); |
| 19 | - const app = fixture.componentInstance; | 22 | + expect(fixture.componentInstance.title).toEqual('RoadTrip'); |
| 20 | - expect(app.title).toEqual('roadTrip'); | ||
| 21 | }); | 23 | }); |
| 22 | 24 | ||
| 23 | - it('should render title', () => { | 25 | + it('affiche la marque et la navigation principale', () => { |
| 24 | const fixture = TestBed.createComponent(AppComponent); | 26 | const fixture = TestBed.createComponent(AppComponent); |
| 25 | fixture.detectChanges(); | 27 | fixture.detectChanges(); |
| 26 | - const compiled = fixture.nativeElement as HTMLElement; | 28 | + const root = fixture.nativeElement as HTMLElement; |
| 27 | - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, roadTrip'); | 29 | + |
| 30 | + expect(root.querySelector('.brand')?.textContent).toContain('RoadTrip'); | ||
| 31 | + const links = Array.from(root.querySelectorAll('nav a')).map((link) => link.getAttribute('href')); | ||
| 32 | + expect(links).toEqual(['/resume', '/souhaits', '/lieux/nouveau']); | ||
| 33 | + }); | ||
| 34 | + | ||
| 35 | + it('affiche le nombre de destinations dans la liste de souhaits', () => { | ||
| 36 | + const fixture = TestBed.createComponent(AppComponent); | ||
| 37 | + fixture.detectChanges(); | ||
| 38 | + | ||
| 39 | + expect((fixture.nativeElement as HTMLElement).querySelector('.count')?.textContent?.trim()).toBe('5'); | ||
| 28 | }); | 40 | }); |
| 29 | }); | 41 | }); |
modified
src/app/app.component.ts +7 -4 | @@ -1,13 +1,16 @@ | ||
| 1 | 1 | import { Component } from '@angular/core'; |
| 2 | -import { RouterOutlet } from '@angular/router'; | |
| 2 | +import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; | |
| 3 | +import { TravelStore } from './travel/services/travel-store.service'; | |
| 3 | 4 | |
| 4 | 5 | @Component({ |
| 5 | 6 | selector: 'app-root', |
| 6 | 7 | standalone: true, |
| 7 | - imports: [RouterOutlet], | |
| 8 | + imports: [RouterOutlet, RouterLink, RouterLinkActive], | |
| 8 | 9 | templateUrl: './app.component.html', |
| 9 | - styleUrl: './app.component.css' | |
| 10 | + styleUrl: './app.component.css', | |
| 10 | 11 | }) |
| 11 | 12 | export class AppComponent { |
| 12 | - title = 'roadTrip'; | |
| 13 | + title = 'RoadTrip'; | |
| 14 | + | |
| 15 | + constructor(public store: TravelStore) {} | |
| 13 | 16 | } |
| @@ -1,13 +1,16 @@ | |||
| 1 | import { Component } from '@angular/core'; | 1 | import { Component } from '@angular/core'; |
| 2 | -import { RouterOutlet } from '@angular/router'; | 2 | +import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; |
| 3 | +import { TravelStore } from './travel/services/travel-store.service'; | ||
| 3 | 4 | ||
| 4 | @Component({ | 5 | @Component({ |
| 5 | selector: 'app-root', | 6 | selector: 'app-root', |
| 6 | standalone: true, | 7 | standalone: true, |
| 7 | - imports: [RouterOutlet], | 8 | + imports: [RouterOutlet, RouterLink, RouterLinkActive], |
| 8 | templateUrl: './app.component.html', | 9 | templateUrl: './app.component.html', |
| 9 | - styleUrl: './app.component.css' | 10 | + styleUrl: './app.component.css', |
| 10 | }) | 11 | }) |
| 11 | export class AppComponent { | 12 | export class AppComponent { |
| 12 | - title = 'roadTrip'; | 13 | + title = 'RoadTrip'; |
| 14 | + | ||
| 15 | + constructor(public store: TravelStore) {} | ||
| 13 | } | 16 | } |
modified
src/app/app.config.ts +10 -2 | @@ -1,8 +1,16 @@ | ||
| 1 | -import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; | |
| 1 | +import { registerLocaleData } from '@angular/common'; | |
| 2 | +import localeFr from '@angular/common/locales/fr'; | |
| 3 | +import { ApplicationConfig, LOCALE_ID, provideZoneChangeDetection } from '@angular/core'; | |
| 2 | 4 | import { provideRouter } from '@angular/router'; |
| 3 | 5 | |
| 4 | 6 | import { routes } from './app.routes'; |
| 5 | 7 | |
| 8 | +registerLocaleData(localeFr); | |
| 9 | + | |
| 6 | 10 | export const appConfig: ApplicationConfig = { |
| 7 | - providers: [provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes)] | |
| 11 | + providers: [ | |
| 12 | + provideZoneChangeDetection({ eventCoalescing: true }), | |
| 13 | + provideRouter(routes), | |
| 14 | + { provide: LOCALE_ID, useValue: 'fr' }, | |
| 15 | + ], | |
| 8 | 16 | }; |
| @@ -1,8 +1,16 @@ | |||
| 1 | -import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; | 1 | +import { registerLocaleData } from '@angular/common'; |
| 2 | +import localeFr from '@angular/common/locales/fr'; | ||
| 3 | +import { ApplicationConfig, LOCALE_ID, provideZoneChangeDetection } from '@angular/core'; | ||
| 2 | import { provideRouter } from '@angular/router'; | 4 | import { provideRouter } from '@angular/router'; |
| 3 | 5 | ||
| 4 | import { routes } from './app.routes'; | 6 | import { routes } from './app.routes'; |
| 5 | 7 | ||
| 8 | +registerLocaleData(localeFr); | ||
| 9 | + | ||
| 6 | export const appConfig: ApplicationConfig = { | 10 | export const appConfig: ApplicationConfig = { |
| 7 | - providers: [provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes)] | 11 | + providers: [ |
| 12 | + provideZoneChangeDetection({ eventCoalescing: true }), | ||
| 13 | + provideRouter(routes), | ||
| 14 | + { provide: LOCALE_ID, useValue: 'fr' }, | ||
| 15 | + ], | ||
| 8 | }; | 16 | }; |
modified
src/app/app.routes.ts +22 -1 | @@ -1,3 +1,24 @@ | ||
| 1 | 1 | import { Routes } from '@angular/router'; |
| 2 | 2 | |
| 3 | -export const routes: Routes = []; | |
| 3 | +export const routes: Routes = [ | |
| 4 | + { path: '', pathMatch: 'full', redirectTo: 'resume' }, | |
| 5 | + { | |
| 6 | + path: 'resume', | |
| 7 | + title: 'Résumé · RoadTrip', | |
| 8 | + loadComponent: () => | |
| 9 | + import('./travel/pages/summary-page/summary-page.component').then((m) => m.SummaryPageComponent), | |
| 10 | + }, | |
| 11 | + { | |
| 12 | + path: 'lieux/nouveau', | |
| 13 | + title: 'Ajouter un lieu · RoadTrip', | |
| 14 | + loadComponent: () => | |
| 15 | + import('./travel/pages/place-form-page/place-form-page.component').then((m) => m.PlaceFormPageComponent), | |
| 16 | + }, | |
| 17 | + { | |
| 18 | + path: 'souhaits', | |
| 19 | + title: 'Liste de souhaits · RoadTrip', | |
| 20 | + loadComponent: () => | |
| 21 | + import('./travel/pages/wishlist-page/wishlist-page.component').then((m) => m.WishlistPageComponent), | |
| 22 | + }, | |
| 23 | + { path: '**', redirectTo: 'resume' }, | |
| 24 | +]; | |
| @@ -1,3 +1,24 @@ | |||
| 1 | import { Routes } from '@angular/router'; | 1 | import { Routes } from '@angular/router'; |
| 2 | 2 | ||
| 3 | -export const routes: Routes = []; | 3 | +export const routes: Routes = [ |
| 4 | + { path: '', pathMatch: 'full', redirectTo: 'resume' }, | ||
| 5 | + { | ||
| 6 | + path: 'resume', | ||
| 7 | + title: 'Résumé · RoadTrip', | ||
| 8 | + loadComponent: () => | ||
| 9 | + import('./travel/pages/summary-page/summary-page.component').then((m) => m.SummaryPageComponent), | ||
| 10 | + }, | ||
| 11 | + { | ||
| 12 | + path: 'lieux/nouveau', | ||
| 13 | + title: 'Ajouter un lieu · RoadTrip', | ||
| 14 | + loadComponent: () => | ||
| 15 | + import('./travel/pages/place-form-page/place-form-page.component').then((m) => m.PlaceFormPageComponent), | ||
| 16 | + }, | ||
| 17 | + { | ||
| 18 | + path: 'souhaits', | ||
| 19 | + title: 'Liste de souhaits · RoadTrip', | ||
| 20 | + loadComponent: () => | ||
| 21 | + import('./travel/pages/wishlist-page/wishlist-page.component').then((m) => m.WishlistPageComponent), | ||
| 22 | + }, | ||
| 23 | + { path: '**', redirectTo: 'resume' }, | ||
| 24 | +]; | ||
added
src/app/travel/components/rating/rating.component.ts +29 -0 | new file mode 100644 | ||
| @@ -0,0 +1,29 @@ | ||
| 1 | +import { NgIf } from '@angular/common'; | |
| 2 | +import { Component, Input } from '@angular/core'; | |
| 3 | + | |
| 4 | +@Component({ | |
| 5 | + selector: 'app-rating', | |
| 6 | + standalone: true, | |
| 7 | + imports: [NgIf], | |
| 8 | + template: ` | |
| 9 | + <span *ngIf="value !== undefined; else noRating" class="rating" role="img" [attr.aria-label]="value + ' sur 5'">{{ | |
| 10 | + stars | |
| 11 | + }}</span> | |
| 12 | + <ng-template #noRating><span class="muted">Non noté</span></ng-template> | |
| 13 | + `, | |
| 14 | + styles: ` | |
| 15 | + .rating { | |
| 16 | + color: var(--wish); | |
| 17 | + letter-spacing: 0.1em; | |
| 18 | + white-space: nowrap; | |
| 19 | + } | |
| 20 | + `, | |
| 21 | +}) | |
| 22 | +export class RatingComponent { | |
| 23 | + @Input() value?: number; | |
| 24 | + | |
| 25 | + get stars(): string { | |
| 26 | + const filled = Math.max(0, Math.min(5, Math.round(this.value || 0))); | |
| 27 | + return '★'.repeat(filled) + '☆'.repeat(5 - filled); | |
| 28 | + } | |
| 29 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,29 @@ | |||
| 1 | +import { NgIf } from '@angular/common'; | ||
| 2 | +import { Component, Input } from '@angular/core'; | ||
| 3 | + | ||
| 4 | +@Component({ | ||
| 5 | + selector: 'app-rating', | ||
| 6 | + standalone: true, | ||
| 7 | + imports: [NgIf], | ||
| 8 | + template: ` | ||
| 9 | + <span *ngIf="value !== undefined; else noRating" class="rating" role="img" [attr.aria-label]="value + ' sur 5'">{{ | ||
| 10 | + stars | ||
| 11 | + }}</span> | ||
| 12 | + <ng-template #noRating><span class="muted">Non noté</span></ng-template> | ||
| 13 | + `, | ||
| 14 | + styles: ` | ||
| 15 | + .rating { | ||
| 16 | + color: var(--wish); | ||
| 17 | + letter-spacing: 0.1em; | ||
| 18 | + white-space: nowrap; | ||
| 19 | + } | ||
| 20 | + `, | ||
| 21 | +}) | ||
| 22 | +export class RatingComponent { | ||
| 23 | + @Input() value?: number; | ||
| 24 | + | ||
| 25 | + get stars(): string { | ||
| 26 | + const filled = Math.max(0, Math.min(5, Math.round(this.value || 0))); | ||
| 27 | + return '★'.repeat(filled) + '☆'.repeat(5 - filled); | ||
| 28 | + } | ||
| 29 | +} | ||
added
src/app/travel/components/travel-map/travel-map.component.spec.ts +92 -0 | new file mode 100644 | ||
| @@ -0,0 +1,92 @@ | ||
| 1 | +import { ComponentFixture, TestBed } from '@angular/core/testing'; | |
| 2 | +import { Place } from '../../models/place.model'; | |
| 3 | +import { TravelMapComponent } from './travel-map.component'; | |
| 4 | + | |
| 5 | +const places: Place[] = [ | |
| 6 | + { id: '1', name: 'Lisbonne', country: 'Portugal', category: 'city', status: 'visited', lat: 38.72, lng: -9.14 }, | |
| 7 | + { | |
| 8 | + id: '2', | |
| 9 | + name: 'Kyoto <b>', | |
| 10 | + country: 'Japon', | |
| 11 | + category: 'culture', | |
| 12 | + status: 'wishlist', | |
| 13 | + lat: 35.01, | |
| 14 | + lng: 135.77, | |
| 15 | + }, | |
| 16 | +]; | |
| 17 | + | |
| 18 | +describe('TravelMapComponent', () => { | |
| 19 | + let fixture: ComponentFixture<TravelMapComponent>; | |
| 20 | + | |
| 21 | + function render(): HTMLElement { | |
| 22 | + fixture.detectChanges(); // ngAfterViewInit crée la carte | |
| 23 | + fixture.detectChanges(); // les effets dessinent les repères | |
| 24 | + return fixture.nativeElement as HTMLElement; | |
| 25 | + } | |
| 26 | + | |
| 27 | + beforeEach(() => { | |
| 28 | + TestBed.configureTestingModule({ imports: [TravelMapComponent] }); | |
| 29 | + fixture = TestBed.createComponent(TravelMapComponent); | |
| 30 | + // La carte a besoin d'une taille pour calculer ses repères. | |
| 31 | + (fixture.nativeElement as HTMLElement).style.cssText = 'display:block;width:600px;height:400px'; | |
| 32 | + }); | |
| 33 | + | |
| 34 | + it('crée la carte Leaflet', () => { | |
| 35 | + expect(render().querySelector('.leaflet-container')).not.toBeNull(); | |
| 36 | + }); | |
| 37 | + | |
| 38 | + it('affiche un repère par lieu, avec un style selon le statut', () => { | |
| 39 | + fixture.componentRef.setInput('places', places); | |
| 40 | + const root = render(); | |
| 41 | + | |
| 42 | + expect(root.querySelectorAll('.pin').length).toBe(2); | |
| 43 | + expect(root.querySelectorAll('.pin--visited').length).toBe(1); | |
| 44 | + expect(root.querySelectorAll('.pin--wishlist').length).toBe(1); | |
| 45 | + }); | |
| 46 | + | |
| 47 | + it('met à jour les repères quand la liste change', () => { | |
| 48 | + fixture.componentRef.setInput('places', places); | |
| 49 | + const root = render(); | |
| 50 | + | |
| 51 | + fixture.componentRef.setInput('places', places.slice(0, 1)); | |
| 52 | + fixture.detectChanges(); | |
| 53 | + | |
| 54 | + expect(root.querySelectorAll('.pin').length).toBe(1); | |
| 55 | + }); | |
| 56 | + | |
| 57 | + it('affiche le point à placer', () => { | |
| 58 | + fixture.componentRef.setInput('picked', { lat: 10, lng: 20 }); | |
| 59 | + expect(render().querySelectorAll('.pin--picked').length).toBe(1); | |
| 60 | + }); | |
| 61 | + | |
| 62 | + it('émet les coordonnées au clic quand la sélection est activée', () => { | |
| 63 | + fixture.componentRef.setInput('pickable', true); | |
| 64 | + const root = render(); | |
| 65 | + const picked: unknown[] = []; | |
| 66 | + fixture.componentInstance.locationPicked.subscribe((point) => picked.push(point)); | |
| 67 | + | |
| 68 | + const container = root.querySelector('.leaflet-container') as HTMLElement; | |
| 69 | + const rect = container.getBoundingClientRect(); | |
| 70 | + container.dispatchEvent( | |
| 71 | + new MouseEvent('click', { | |
| 72 | + bubbles: true, | |
| 73 | + clientX: rect.left + rect.width / 2, | |
| 74 | + clientY: rect.top + rect.height / 2, | |
| 75 | + }), | |
| 76 | + ); | |
| 77 | + | |
| 78 | + expect(picked.length).toBe(1); | |
| 79 | + expect(picked[0]).toEqual({ lat: jasmine.any(Number), lng: jasmine.any(Number) }); | |
| 80 | + }); | |
| 81 | + | |
| 82 | + it('n’émet rien au clic quand la sélection est désactivée', () => { | |
| 83 | + const root = render(); | |
| 84 | + const picked: unknown[] = []; | |
| 85 | + fixture.componentInstance.locationPicked.subscribe((point) => picked.push(point)); | |
| 86 | + | |
| 87 | + const container = root.querySelector('.leaflet-container') as HTMLElement; | |
| 88 | + container.dispatchEvent(new MouseEvent('click', { bubbles: true, clientX: 100, clientY: 100 })); | |
| 89 | + | |
| 90 | + expect(picked).toEqual([]); | |
| 91 | + }); | |
| 92 | +}); | |
| new file mode 100644 | |||
| @@ -0,0 +1,92 @@ | |||
| 1 | +import { ComponentFixture, TestBed } from '@angular/core/testing'; | ||
| 2 | +import { Place } from '../../models/place.model'; | ||
| 3 | +import { TravelMapComponent } from './travel-map.component'; | ||
| 4 | + | ||
| 5 | +const places: Place[] = [ | ||
| 6 | + { id: '1', name: 'Lisbonne', country: 'Portugal', category: 'city', status: 'visited', lat: 38.72, lng: -9.14 }, | ||
| 7 | + { | ||
| 8 | + id: '2', | ||
| 9 | + name: 'Kyoto <b>', | ||
| 10 | + country: 'Japon', | ||
| 11 | + category: 'culture', | ||
| 12 | + status: 'wishlist', | ||
| 13 | + lat: 35.01, | ||
| 14 | + lng: 135.77, | ||
| 15 | + }, | ||
| 16 | +]; | ||
| 17 | + | ||
| 18 | +describe('TravelMapComponent', () => { | ||
| 19 | + let fixture: ComponentFixture<TravelMapComponent>; | ||
| 20 | + | ||
| 21 | + function render(): HTMLElement { | ||
| 22 | + fixture.detectChanges(); // ngAfterViewInit crée la carte | ||
| 23 | + fixture.detectChanges(); // les effets dessinent les repères | ||
| 24 | + return fixture.nativeElement as HTMLElement; | ||
| 25 | + } | ||
| 26 | + | ||
| 27 | + beforeEach(() => { | ||
| 28 | + TestBed.configureTestingModule({ imports: [TravelMapComponent] }); | ||
| 29 | + fixture = TestBed.createComponent(TravelMapComponent); | ||
| 30 | + // La carte a besoin d'une taille pour calculer ses repères. | ||
| 31 | + (fixture.nativeElement as HTMLElement).style.cssText = 'display:block;width:600px;height:400px'; | ||
| 32 | + }); | ||
| 33 | + | ||
| 34 | + it('crée la carte Leaflet', () => { | ||
| 35 | + expect(render().querySelector('.leaflet-container')).not.toBeNull(); | ||
| 36 | + }); | ||
| 37 | + | ||
| 38 | + it('affiche un repère par lieu, avec un style selon le statut', () => { | ||
| 39 | + fixture.componentRef.setInput('places', places); | ||
| 40 | + const root = render(); | ||
| 41 | + | ||
| 42 | + expect(root.querySelectorAll('.pin').length).toBe(2); | ||
| 43 | + expect(root.querySelectorAll('.pin--visited').length).toBe(1); | ||
| 44 | + expect(root.querySelectorAll('.pin--wishlist').length).toBe(1); | ||
| 45 | + }); | ||
| 46 | + | ||
| 47 | + it('met à jour les repères quand la liste change', () => { | ||
| 48 | + fixture.componentRef.setInput('places', places); | ||
| 49 | + const root = render(); | ||
| 50 | + | ||
| 51 | + fixture.componentRef.setInput('places', places.slice(0, 1)); | ||
| 52 | + fixture.detectChanges(); | ||
| 53 | + | ||
| 54 | + expect(root.querySelectorAll('.pin').length).toBe(1); | ||
| 55 | + }); | ||
| 56 | + | ||
| 57 | + it('affiche le point à placer', () => { | ||
| 58 | + fixture.componentRef.setInput('picked', { lat: 10, lng: 20 }); | ||
| 59 | + expect(render().querySelectorAll('.pin--picked').length).toBe(1); | ||
| 60 | + }); | ||
| 61 | + | ||
| 62 | + it('émet les coordonnées au clic quand la sélection est activée', () => { | ||
| 63 | + fixture.componentRef.setInput('pickable', true); | ||
| 64 | + const root = render(); | ||
| 65 | + const picked: unknown[] = []; | ||
| 66 | + fixture.componentInstance.locationPicked.subscribe((point) => picked.push(point)); | ||
| 67 | + | ||
| 68 | + const container = root.querySelector('.leaflet-container') as HTMLElement; | ||
| 69 | + const rect = container.getBoundingClientRect(); | ||
| 70 | + container.dispatchEvent( | ||
| 71 | + new MouseEvent('click', { | ||
| 72 | + bubbles: true, | ||
| 73 | + clientX: rect.left + rect.width / 2, | ||
| 74 | + clientY: rect.top + rect.height / 2, | ||
| 75 | + }), | ||
| 76 | + ); | ||
| 77 | + | ||
| 78 | + expect(picked.length).toBe(1); | ||
| 79 | + expect(picked[0]).toEqual({ lat: jasmine.any(Number), lng: jasmine.any(Number) }); | ||
| 80 | + }); | ||
| 81 | + | ||
| 82 | + it('n’émet rien au clic quand la sélection est désactivée', () => { | ||
| 83 | + const root = render(); | ||
| 84 | + const picked: unknown[] = []; | ||
| 85 | + fixture.componentInstance.locationPicked.subscribe((point) => picked.push(point)); | ||
| 86 | + | ||
| 87 | + const container = root.querySelector('.leaflet-container') as HTMLElement; | ||
| 88 | + container.dispatchEvent(new MouseEvent('click', { bubbles: true, clientX: 100, clientY: 100 })); | ||
| 89 | + | ||
| 90 | + expect(picked).toEqual([]); | ||
| 91 | + }); | ||
| 92 | +}); | ||
added
src/app/travel/components/travel-map/travel-map.component.ts +171 -0 | new file mode 100644 | ||
| @@ -0,0 +1,171 @@ | ||
| 1 | +import { | |
| 2 | + AfterViewInit, | |
| 3 | + Component, | |
| 4 | + ElementRef, | |
| 5 | + EventEmitter, | |
| 6 | + Input, | |
| 7 | + OnChanges, | |
| 8 | + OnDestroy, | |
| 9 | + Output, | |
| 10 | + SimpleChanges, | |
| 11 | + ViewChild, | |
| 12 | + ViewEncapsulation, | |
| 13 | +} from '@angular/core'; | |
| 14 | +import * as L from 'leaflet'; | |
| 15 | +import { CATEGORY_LABELS, LatLng, Place } from '../../models/place.model'; | |
| 16 | +import { formatIsoDate } from '../../utils/date'; | |
| 17 | + | |
| 18 | +type PinKind = Place['status'] | 'picked'; | |
| 19 | + | |
| 20 | +@Component({ | |
| 21 | + selector: 'app-travel-map', | |
| 22 | + standalone: true, | |
| 23 | + // Les repères sont créés par Leaflet hors du template : leurs styles doivent être globaux. | |
| 24 | + encapsulation: ViewEncapsulation.None, | |
| 25 | + template: `<div #surface class="travel-map" role="region" [attr.aria-label]="label"></div>`, | |
| 26 | + styles: ` | |
| 27 | + app-travel-map { | |
| 28 | + display: block; | |
| 29 | + height: 100%; | |
| 30 | + } | |
| 31 | + .travel-map { | |
| 32 | + position: relative; | |
| 33 | + z-index: 0; | |
| 34 | + height: 100%; | |
| 35 | + min-height: 320px; | |
| 36 | + border-radius: var(--radius); | |
| 37 | + } | |
| 38 | + .pin { | |
| 39 | + box-sizing: border-box; | |
| 40 | + border: 3px solid #fff; | |
| 41 | + border-radius: 50%; | |
| 42 | + box-shadow: 0 1px 5px rgb(0 0 0 / 0.5); | |
| 43 | + } | |
| 44 | + .pin--visited { | |
| 45 | + background: var(--visited); | |
| 46 | + } | |
| 47 | + .pin--wishlist { | |
| 48 | + background: #fff; | |
| 49 | + border: 3px dashed var(--wish); | |
| 50 | + } | |
| 51 | + .pin--picked { | |
| 52 | + background: #111; | |
| 53 | + box-shadow: 0 0 0 4px rgb(255 255 255 / 0.85), 0 1px 6px rgb(0 0 0 / 0.6); | |
| 54 | + } | |
| 55 | + `, | |
| 56 | +}) | |
| 57 | +export class TravelMapComponent implements OnChanges, AfterViewInit, OnDestroy { | |
| 58 | + @Input() places: readonly Place[] = []; | |
| 59 | + /** Active la sélection d'un point par clic sur la carte. */ | |
| 60 | + @Input() pickable = false; | |
| 61 | + /** Point mis en évidence (par exemple le lieu en cours de saisie). */ | |
| 62 | + @Input() picked: LatLng | null = null; | |
| 63 | + @Input() label = 'Carte des lieux'; | |
| 64 | + | |
| 65 | + @Output() locationPicked = new EventEmitter<LatLng>(); | |
| 66 | + | |
| 67 | + @ViewChild('surface', { static: true }) surface!: ElementRef<HTMLDivElement>; | |
| 68 | + | |
| 69 | + private readonly markers = L.layerGroup(); | |
| 70 | + private map: L.Map | undefined; | |
| 71 | + private pickedMarker: L.Marker | undefined; | |
| 72 | + | |
| 73 | + ngOnChanges(changes: SimpleChanges): void { | |
| 74 | + // Avant ngAfterViewInit, la carte n'existe pas encore : le premier dessin y est fait. | |
| 75 | + if (!this.map) { | |
| 76 | + return; | |
| 77 | + } | |
| 78 | + if (changes['places']) { | |
| 79 | + this.drawPlaces(this.places); | |
| 80 | + } | |
| 81 | + if (changes['picked']) { | |
| 82 | + this.drawPicked(this.picked); | |
| 83 | + } | |
| 84 | + } | |
| 85 | + | |
| 86 | + ngAfterViewInit(): void { | |
| 87 | + const map = L.map(this.surface.nativeElement, { worldCopyJump: true, minZoom: 2 }).setView([30, 10], 2); | |
| 88 | + | |
| 89 | + L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { | |
| 90 | + maxZoom: 18, | |
| 91 | + attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>', | |
| 92 | + }).addTo(map); | |
| 93 | + this.markers.addTo(map); | |
| 94 | + | |
| 95 | + map.on('click', (event: L.LeafletMouseEvent) => { | |
| 96 | + if (this.pickable) { | |
| 97 | + const { lat, lng } = event.latlng.wrap(); | |
| 98 | + this.locationPicked.emit({ lat: round(lat), lng: round(lng) }); | |
| 99 | + } | |
| 100 | + }); | |
| 101 | + | |
| 102 | + this.map = map; | |
| 103 | + this.drawPlaces(this.places); | |
| 104 | + this.drawPicked(this.picked); | |
| 105 | + } | |
| 106 | + | |
| 107 | + ngOnDestroy(): void { | |
| 108 | + this.map?.remove(); | |
| 109 | + this.map = undefined; | |
| 110 | + } | |
| 111 | + | |
| 112 | + private drawPlaces(places: readonly Place[]): void { | |
| 113 | + this.markers.clearLayers(); | |
| 114 | + | |
| 115 | + for (const place of places) { | |
| 116 | + L.marker([place.lat, place.lng], { icon: pinIcon(place.status), title: place.name }) | |
| 117 | + .bindPopup(popupContent(place)) | |
| 118 | + .addTo(this.markers); | |
| 119 | + } | |
| 120 | + | |
| 121 | + if (!this.pickable && places.length > 0) { | |
| 122 | + const bounds = L.latLngBounds(places.map((place): L.LatLngTuple => [place.lat, place.lng])); | |
| 123 | + // Marge plus large en haut à gauche pour ne pas passer sous les boutons de zoom. | |
| 124 | + this.map?.fitBounds(bounds, { paddingTopLeft: [64, 64], paddingBottomRight: [40, 40], maxZoom: 5 }); | |
| 125 | + } | |
| 126 | + } | |
| 127 | + | |
| 128 | + private drawPicked(point: LatLng | null): void { | |
| 129 | + this.pickedMarker?.remove(); | |
| 130 | + this.pickedMarker = undefined; | |
| 131 | + | |
| 132 | + if (point && this.map) { | |
| 133 | + this.pickedMarker = L.marker([point.lat, point.lng], { | |
| 134 | + icon: pinIcon('picked'), | |
| 135 | + interactive: false, | |
| 136 | + keyboard: false, | |
| 137 | + }).addTo(this.map); | |
| 138 | + this.map.panTo([point.lat, point.lng]); | |
| 139 | + } | |
| 140 | + } | |
| 141 | +} | |
| 142 | + | |
| 143 | +function pinIcon(kind: PinKind): L.DivIcon { | |
| 144 | + return L.divIcon({ | |
| 145 | + className: `pin pin--${kind}`, | |
| 146 | + iconSize: [18, 18], | |
| 147 | + iconAnchor: [9, 9], | |
| 148 | + popupAnchor: [0, -10], | |
| 149 | + }); | |
| 150 | +} | |
| 151 | + | |
| 152 | +/** Contenu de la bulle construit via le DOM : les saisies de l'utilisateur ne sont jamais interprétées comme du HTML. */ | |
| 153 | +function popupContent(place: Place): HTMLElement { | |
| 154 | + const root = document.createElement('div'); | |
| 155 | + const title = document.createElement('strong'); | |
| 156 | + title.textContent = place.name; | |
| 157 | + | |
| 158 | + const meta = document.createElement('div'); | |
| 159 | + meta.textContent = `${place.country} · ${CATEGORY_LABELS[place.category]}`; | |
| 160 | + | |
| 161 | + const status = document.createElement('div'); | |
| 162 | + status.textContent = | |
| 163 | + place.status === 'visited' && place.visitedOn ? `Visité le ${formatIsoDate(place.visitedOn)}` : 'À visiter'; | |
| 164 | + | |
| 165 | + root.append(title, meta, status); | |
| 166 | + return root; | |
| 167 | +} | |
| 168 | + | |
| 169 | +function round(value: number): number { | |
| 170 | + return Math.round(value * 1e4) / 1e4; | |
| 171 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,171 @@ | |||
| 1 | +import { | ||
| 2 | + AfterViewInit, | ||
| 3 | + Component, | ||
| 4 | + ElementRef, | ||
| 5 | + EventEmitter, | ||
| 6 | + Input, | ||
| 7 | + OnChanges, | ||
| 8 | + OnDestroy, | ||
| 9 | + Output, | ||
| 10 | + SimpleChanges, | ||
| 11 | + ViewChild, | ||
| 12 | + ViewEncapsulation, | ||
| 13 | +} from '@angular/core'; | ||
| 14 | +import * as L from 'leaflet'; | ||
| 15 | +import { CATEGORY_LABELS, LatLng, Place } from '../../models/place.model'; | ||
| 16 | +import { formatIsoDate } from '../../utils/date'; | ||
| 17 | + | ||
| 18 | +type PinKind = Place['status'] | 'picked'; | ||
| 19 | + | ||
| 20 | +@Component({ | ||
| 21 | + selector: 'app-travel-map', | ||
| 22 | + standalone: true, | ||
| 23 | + // Les repères sont créés par Leaflet hors du template : leurs styles doivent être globaux. | ||
| 24 | + encapsulation: ViewEncapsulation.None, | ||
| 25 | + template: `<div #surface class="travel-map" role="region" [attr.aria-label]="label"></div>`, | ||
| 26 | + styles: ` | ||
| 27 | + app-travel-map { | ||
| 28 | + display: block; | ||
| 29 | + height: 100%; | ||
| 30 | + } | ||
| 31 | + .travel-map { | ||
| 32 | + position: relative; | ||
| 33 | + z-index: 0; | ||
| 34 | + height: 100%; | ||
| 35 | + min-height: 320px; | ||
| 36 | + border-radius: var(--radius); | ||
| 37 | + } | ||
| 38 | + .pin { | ||
| 39 | + box-sizing: border-box; | ||
| 40 | + border: 3px solid #fff; | ||
| 41 | + border-radius: 50%; | ||
| 42 | + box-shadow: 0 1px 5px rgb(0 0 0 / 0.5); | ||
| 43 | + } | ||
| 44 | + .pin--visited { | ||
| 45 | + background: var(--visited); | ||
| 46 | + } | ||
| 47 | + .pin--wishlist { | ||
| 48 | + background: #fff; | ||
| 49 | + border: 3px dashed var(--wish); | ||
| 50 | + } | ||
| 51 | + .pin--picked { | ||
| 52 | + background: #111; | ||
| 53 | + box-shadow: 0 0 0 4px rgb(255 255 255 / 0.85), 0 1px 6px rgb(0 0 0 / 0.6); | ||
| 54 | + } | ||
| 55 | + `, | ||
| 56 | +}) | ||
| 57 | +export class TravelMapComponent implements OnChanges, AfterViewInit, OnDestroy { | ||
| 58 | + @Input() places: readonly Place[] = []; | ||
| 59 | + /** Active la sélection d'un point par clic sur la carte. */ | ||
| 60 | + @Input() pickable = false; | ||
| 61 | + /** Point mis en évidence (par exemple le lieu en cours de saisie). */ | ||
| 62 | + @Input() picked: LatLng | null = null; | ||
| 63 | + @Input() label = 'Carte des lieux'; | ||
| 64 | + | ||
| 65 | + @Output() locationPicked = new EventEmitter<LatLng>(); | ||
| 66 | + | ||
| 67 | + @ViewChild('surface', { static: true }) surface!: ElementRef<HTMLDivElement>; | ||
| 68 | + | ||
| 69 | + private readonly markers = L.layerGroup(); | ||
| 70 | + private map: L.Map | undefined; | ||
| 71 | + private pickedMarker: L.Marker | undefined; | ||
| 72 | + | ||
| 73 | + ngOnChanges(changes: SimpleChanges): void { | ||
| 74 | + // Avant ngAfterViewInit, la carte n'existe pas encore : le premier dessin y est fait. | ||
| 75 | + if (!this.map) { | ||
| 76 | + return; | ||
| 77 | + } | ||
| 78 | + if (changes['places']) { | ||
| 79 | + this.drawPlaces(this.places); | ||
| 80 | + } | ||
| 81 | + if (changes['picked']) { | ||
| 82 | + this.drawPicked(this.picked); | ||
| 83 | + } | ||
| 84 | + } | ||
| 85 | + | ||
| 86 | + ngAfterViewInit(): void { | ||
| 87 | + const map = L.map(this.surface.nativeElement, { worldCopyJump: true, minZoom: 2 }).setView([30, 10], 2); | ||
| 88 | + | ||
| 89 | + L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { | ||
| 90 | + maxZoom: 18, | ||
| 91 | + attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>', | ||
| 92 | + }).addTo(map); | ||
| 93 | + this.markers.addTo(map); | ||
| 94 | + | ||
| 95 | + map.on('click', (event: L.LeafletMouseEvent) => { | ||
| 96 | + if (this.pickable) { | ||
| 97 | + const { lat, lng } = event.latlng.wrap(); | ||
| 98 | + this.locationPicked.emit({ lat: round(lat), lng: round(lng) }); | ||
| 99 | + } | ||
| 100 | + }); | ||
| 101 | + | ||
| 102 | + this.map = map; | ||
| 103 | + this.drawPlaces(this.places); | ||
| 104 | + this.drawPicked(this.picked); | ||
| 105 | + } | ||
| 106 | + | ||
| 107 | + ngOnDestroy(): void { | ||
| 108 | + this.map?.remove(); | ||
| 109 | + this.map = undefined; | ||
| 110 | + } | ||
| 111 | + | ||
| 112 | + private drawPlaces(places: readonly Place[]): void { | ||
| 113 | + this.markers.clearLayers(); | ||
| 114 | + | ||
| 115 | + for (const place of places) { | ||
| 116 | + L.marker([place.lat, place.lng], { icon: pinIcon(place.status), title: place.name }) | ||
| 117 | + .bindPopup(popupContent(place)) | ||
| 118 | + .addTo(this.markers); | ||
| 119 | + } | ||
| 120 | + | ||
| 121 | + if (!this.pickable && places.length > 0) { | ||
| 122 | + const bounds = L.latLngBounds(places.map((place): L.LatLngTuple => [place.lat, place.lng])); | ||
| 123 | + // Marge plus large en haut à gauche pour ne pas passer sous les boutons de zoom. | ||
| 124 | + this.map?.fitBounds(bounds, { paddingTopLeft: [64, 64], paddingBottomRight: [40, 40], maxZoom: 5 }); | ||
| 125 | + } | ||
| 126 | + } | ||
| 127 | + | ||
| 128 | + private drawPicked(point: LatLng | null): void { | ||
| 129 | + this.pickedMarker?.remove(); | ||
| 130 | + this.pickedMarker = undefined; | ||
| 131 | + | ||
| 132 | + if (point && this.map) { | ||
| 133 | + this.pickedMarker = L.marker([point.lat, point.lng], { | ||
| 134 | + icon: pinIcon('picked'), | ||
| 135 | + interactive: false, | ||
| 136 | + keyboard: false, | ||
| 137 | + }).addTo(this.map); | ||
| 138 | + this.map.panTo([point.lat, point.lng]); | ||
| 139 | + } | ||
| 140 | + } | ||
| 141 | +} | ||
| 142 | + | ||
| 143 | +function pinIcon(kind: PinKind): L.DivIcon { | ||
| 144 | + return L.divIcon({ | ||
| 145 | + className: `pin pin--${kind}`, | ||
| 146 | + iconSize: [18, 18], | ||
| 147 | + iconAnchor: [9, 9], | ||
| 148 | + popupAnchor: [0, -10], | ||
| 149 | + }); | ||
| 150 | +} | ||
| 151 | + | ||
| 152 | +/** Contenu de la bulle construit via le DOM : les saisies de l'utilisateur ne sont jamais interprétées comme du HTML. */ | ||
| 153 | +function popupContent(place: Place): HTMLElement { | ||
| 154 | + const root = document.createElement('div'); | ||
| 155 | + const title = document.createElement('strong'); | ||
| 156 | + title.textContent = place.name; | ||
| 157 | + | ||
| 158 | + const meta = document.createElement('div'); | ||
| 159 | + meta.textContent = `${place.country} · ${CATEGORY_LABELS[place.category]}`; | ||
| 160 | + | ||
| 161 | + const status = document.createElement('div'); | ||
| 162 | + status.textContent = | ||
| 163 | + place.status === 'visited' && place.visitedOn ? `Visité le ${formatIsoDate(place.visitedOn)}` : 'À visiter'; | ||
| 164 | + | ||
| 165 | + root.append(title, meta, status); | ||
| 166 | + return root; | ||
| 167 | +} | ||
| 168 | + | ||
| 169 | +function round(value: number): number { | ||
| 170 | + return Math.round(value * 1e4) / 1e4; | ||
| 171 | +} | ||
added
src/app/travel/data/seed-places.ts +118 -0 | new file mode 100644 | ||
| @@ -0,0 +1,118 @@ | ||
| 1 | +import { Place } from '../models/place.model'; | |
| 2 | + | |
| 3 | +/** Lieux d'exemple affichés tant que l'utilisateur n'a rien enregistré. */ | |
| 4 | +export const SEED_PLACES: readonly Place[] = [ | |
| 5 | + { | |
| 6 | + id: 'seed-lisbonne', | |
| 7 | + name: 'Lisbonne', | |
| 8 | + country: 'Portugal', | |
| 9 | + category: 'city', | |
| 10 | + status: 'visited', | |
| 11 | + lat: 38.7223, | |
| 12 | + lng: -9.1393, | |
| 13 | + visitedOn: '2023-05-14', | |
| 14 | + rating: 5, | |
| 15 | + note: 'Tram 28, pastéis de nata et couchers de soleil sur le Tage.', | |
| 16 | + }, | |
| 17 | + { | |
| 18 | + id: 'seed-galibier', | |
| 19 | + name: 'Col du Galibier', | |
| 20 | + country: 'France', | |
| 21 | + category: 'moto-road', | |
| 22 | + status: 'visited', | |
| 23 | + lat: 45.0644, | |
| 24 | + lng: 6.4086, | |
| 25 | + visitedOn: '2024-07-06', | |
| 26 | + rating: 5, | |
| 27 | + note: 'Montée jusqu’à 2 642 m, enchaînement de virages et vue sur les Écrins.', | |
| 28 | + }, | |
| 29 | + { | |
| 30 | + id: 'seed-marrakech', | |
| 31 | + name: 'Marrakech', | |
| 32 | + country: 'Maroc', | |
| 33 | + category: 'city', | |
| 34 | + status: 'visited', | |
| 35 | + lat: 31.6295, | |
| 36 | + lng: -7.9811, | |
| 37 | + visitedOn: '2022-11-19', | |
| 38 | + rating: 4, | |
| 39 | + note: 'Médina, souks et thé à la menthe sur les toits.', | |
| 40 | + }, | |
| 41 | + { | |
| 42 | + id: 'seed-reykjavik', | |
| 43 | + name: 'Reykjavik', | |
| 44 | + country: 'Islande', | |
| 45 | + category: 'nature', | |
| 46 | + status: 'visited', | |
| 47 | + lat: 64.1466, | |
| 48 | + lng: -21.9426, | |
| 49 | + visitedOn: '2023-09-02', | |
| 50 | + rating: 5, | |
| 51 | + note: 'Cascades, sources chaudes et routes sans fin.', | |
| 52 | + }, | |
| 53 | + { | |
| 54 | + id: 'seed-tokyo', | |
| 55 | + name: 'Tokyo', | |
| 56 | + country: 'Japon', | |
| 57 | + category: 'culture', | |
| 58 | + status: 'visited', | |
| 59 | + lat: 35.6762, | |
| 60 | + lng: 139.6503, | |
| 61 | + visitedOn: '2019-04-03', | |
| 62 | + rating: 4, | |
| 63 | + note: 'Cerisiers en fleurs et ramen de minuit.', | |
| 64 | + }, | |
| 65 | + { | |
| 66 | + id: 'seed-stelvio', | |
| 67 | + name: 'Passo dello Stelvio', | |
| 68 | + country: 'Italie', | |
| 69 | + category: 'moto-road', | |
| 70 | + status: 'wishlist', | |
| 71 | + lat: 46.5284, | |
| 72 | + lng: 10.4533, | |
| 73 | + priority: 'high', | |
| 74 | + note: 'Les lacets du versant nord, en haute saison de préférence tôt le matin.', | |
| 75 | + }, | |
| 76 | + { | |
| 77 | + id: 'seed-transfagarasan', | |
| 78 | + name: 'Transfăgărășan', | |
| 79 | + country: 'Roumanie', | |
| 80 | + category: 'moto-road', | |
| 81 | + status: 'wishlist', | |
| 82 | + lat: 45.6027, | |
| 83 | + lng: 24.6177, | |
| 84 | + priority: 'high', | |
| 85 | + note: 'Route mythique à travers les Carpates, jusqu’au lac de Bâlea.', | |
| 86 | + }, | |
| 87 | + { | |
| 88 | + id: 'seed-queenstown', | |
| 89 | + name: 'Queenstown', | |
| 90 | + country: 'Nouvelle-Zélande', | |
| 91 | + category: 'nature', | |
| 92 | + status: 'wishlist', | |
| 93 | + lat: -45.0312, | |
| 94 | + lng: 168.6626, | |
| 95 | + priority: 'medium', | |
| 96 | + }, | |
| 97 | + { | |
| 98 | + id: 'seed-kyoto', | |
| 99 | + name: 'Kyoto', | |
| 100 | + country: 'Japon', | |
| 101 | + category: 'culture', | |
| 102 | + status: 'wishlist', | |
| 103 | + lat: 35.0116, | |
| 104 | + lng: 135.7681, | |
| 105 | + priority: 'medium', | |
| 106 | + note: 'Temples et ruelles de Gion, hors saison des cerisiers.', | |
| 107 | + }, | |
| 108 | + { | |
| 109 | + id: 'seed-lofoten', | |
| 110 | + name: 'Îles Lofoten', | |
| 111 | + country: 'Norvège', | |
| 112 | + category: 'nature', | |
| 113 | + status: 'wishlist', | |
| 114 | + lat: 68.2342, | |
| 115 | + lng: 14.5674, | |
| 116 | + priority: 'low', | |
| 117 | + }, | |
| 118 | +]; | |
| new file mode 100644 | |||
| @@ -0,0 +1,118 @@ | |||
| 1 | +import { Place } from '../models/place.model'; | ||
| 2 | + | ||
| 3 | +/** Lieux d'exemple affichés tant que l'utilisateur n'a rien enregistré. */ | ||
| 4 | +export const SEED_PLACES: readonly Place[] = [ | ||
| 5 | + { | ||
| 6 | + id: 'seed-lisbonne', | ||
| 7 | + name: 'Lisbonne', | ||
| 8 | + country: 'Portugal', | ||
| 9 | + category: 'city', | ||
| 10 | + status: 'visited', | ||
| 11 | + lat: 38.7223, | ||
| 12 | + lng: -9.1393, | ||
| 13 | + visitedOn: '2023-05-14', | ||
| 14 | + rating: 5, | ||
| 15 | + note: 'Tram 28, pastéis de nata et couchers de soleil sur le Tage.', | ||
| 16 | + }, | ||
| 17 | + { | ||
| 18 | + id: 'seed-galibier', | ||
| 19 | + name: 'Col du Galibier', | ||
| 20 | + country: 'France', | ||
| 21 | + category: 'moto-road', | ||
| 22 | + status: 'visited', | ||
| 23 | + lat: 45.0644, | ||
| 24 | + lng: 6.4086, | ||
| 25 | + visitedOn: '2024-07-06', | ||
| 26 | + rating: 5, | ||
| 27 | + note: 'Montée jusqu’à 2 642 m, enchaînement de virages et vue sur les Écrins.', | ||
| 28 | + }, | ||
| 29 | + { | ||
| 30 | + id: 'seed-marrakech', | ||
| 31 | + name: 'Marrakech', | ||
| 32 | + country: 'Maroc', | ||
| 33 | + category: 'city', | ||
| 34 | + status: 'visited', | ||
| 35 | + lat: 31.6295, | ||
| 36 | + lng: -7.9811, | ||
| 37 | + visitedOn: '2022-11-19', | ||
| 38 | + rating: 4, | ||
| 39 | + note: 'Médina, souks et thé à la menthe sur les toits.', | ||
| 40 | + }, | ||
| 41 | + { | ||
| 42 | + id: 'seed-reykjavik', | ||
| 43 | + name: 'Reykjavik', | ||
| 44 | + country: 'Islande', | ||
| 45 | + category: 'nature', | ||
| 46 | + status: 'visited', | ||
| 47 | + lat: 64.1466, | ||
| 48 | + lng: -21.9426, | ||
| 49 | + visitedOn: '2023-09-02', | ||
| 50 | + rating: 5, | ||
| 51 | + note: 'Cascades, sources chaudes et routes sans fin.', | ||
| 52 | + }, | ||
| 53 | + { | ||
| 54 | + id: 'seed-tokyo', | ||
| 55 | + name: 'Tokyo', | ||
| 56 | + country: 'Japon', | ||
| 57 | + category: 'culture', | ||
| 58 | + status: 'visited', | ||
| 59 | + lat: 35.6762, | ||
| 60 | + lng: 139.6503, | ||
| 61 | + visitedOn: '2019-04-03', | ||
| 62 | + rating: 4, | ||
| 63 | + note: 'Cerisiers en fleurs et ramen de minuit.', | ||
| 64 | + }, | ||
| 65 | + { | ||
| 66 | + id: 'seed-stelvio', | ||
| 67 | + name: 'Passo dello Stelvio', | ||
| 68 | + country: 'Italie', | ||
| 69 | + category: 'moto-road', | ||
| 70 | + status: 'wishlist', | ||
| 71 | + lat: 46.5284, | ||
| 72 | + lng: 10.4533, | ||
| 73 | + priority: 'high', | ||
| 74 | + note: 'Les lacets du versant nord, en haute saison de préférence tôt le matin.', | ||
| 75 | + }, | ||
| 76 | + { | ||
| 77 | + id: 'seed-transfagarasan', | ||
| 78 | + name: 'Transfăgărășan', | ||
| 79 | + country: 'Roumanie', | ||
| 80 | + category: 'moto-road', | ||
| 81 | + status: 'wishlist', | ||
| 82 | + lat: 45.6027, | ||
| 83 | + lng: 24.6177, | ||
| 84 | + priority: 'high', | ||
| 85 | + note: 'Route mythique à travers les Carpates, jusqu’au lac de Bâlea.', | ||
| 86 | + }, | ||
| 87 | + { | ||
| 88 | + id: 'seed-queenstown', | ||
| 89 | + name: 'Queenstown', | ||
| 90 | + country: 'Nouvelle-Zélande', | ||
| 91 | + category: 'nature', | ||
| 92 | + status: 'wishlist', | ||
| 93 | + lat: -45.0312, | ||
| 94 | + lng: 168.6626, | ||
| 95 | + priority: 'medium', | ||
| 96 | + }, | ||
| 97 | + { | ||
| 98 | + id: 'seed-kyoto', | ||
| 99 | + name: 'Kyoto', | ||
| 100 | + country: 'Japon', | ||
| 101 | + category: 'culture', | ||
| 102 | + status: 'wishlist', | ||
| 103 | + lat: 35.0116, | ||
| 104 | + lng: 135.7681, | ||
| 105 | + priority: 'medium', | ||
| 106 | + note: 'Temples et ruelles de Gion, hors saison des cerisiers.', | ||
| 107 | + }, | ||
| 108 | + { | ||
| 109 | + id: 'seed-lofoten', | ||
| 110 | + name: 'Îles Lofoten', | ||
| 111 | + country: 'Norvège', | ||
| 112 | + category: 'nature', | ||
| 113 | + status: 'wishlist', | ||
| 114 | + lat: 68.2342, | ||
| 115 | + lng: 14.5674, | ||
| 116 | + priority: 'low', | ||
| 117 | + }, | ||
| 118 | +]; | ||
added
src/app/travel/models/place.model.ts +43 -0 | new file mode 100644 | ||
| @@ -0,0 +1,43 @@ | ||
| 1 | +export type PlaceStatus = 'visited' | 'wishlist'; | |
| 2 | +export type PlaceCategory = 'city' | 'moto-road' | 'nature' | 'culture'; | |
| 3 | +export type PlacePriority = 'high' | 'medium' | 'low'; | |
| 4 | + | |
| 5 | +export interface LatLng { | |
| 6 | + lat: number; | |
| 7 | + lng: number; | |
| 8 | +} | |
| 9 | + | |
| 10 | +export interface Place { | |
| 11 | + readonly id: string; | |
| 12 | + name: string; | |
| 13 | + country: string; | |
| 14 | + category: PlaceCategory; | |
| 15 | + status: PlaceStatus; | |
| 16 | + lat: number; | |
| 17 | + lng: number; | |
| 18 | + /** Date de visite au format ISO (yyyy-MM-dd), uniquement pour les lieux visités. */ | |
| 19 | + visitedOn?: string; | |
| 20 | + /** Note de 1 à 5, facultative. */ | |
| 21 | + rating?: number; | |
| 22 | + /** Priorité, uniquement pour les lieux de la liste de souhaits. */ | |
| 23 | + priority?: PlacePriority; | |
| 24 | + note?: string; | |
| 25 | +} | |
| 26 | + | |
| 27 | +export type NewPlace = Omit<Place, 'id'>; | |
| 28 | + | |
| 29 | +export const CATEGORY_LABELS: Record<PlaceCategory, string> = { | |
| 30 | + city: 'Ville', | |
| 31 | + 'moto-road': 'Route moto', | |
| 32 | + nature: 'Nature', | |
| 33 | + culture: 'Culture', | |
| 34 | +}; | |
| 35 | + | |
| 36 | +export const PRIORITY_LABELS: Record<PlacePriority, string> = { | |
| 37 | + high: 'Priorité haute', | |
| 38 | + medium: 'Priorité moyenne', | |
| 39 | + low: 'Priorité basse', | |
| 40 | +}; | |
| 41 | + | |
| 42 | +export const CATEGORIES = Object.keys(CATEGORY_LABELS) as PlaceCategory[]; | |
| 43 | +export const PRIORITIES = Object.keys(PRIORITY_LABELS) as PlacePriority[]; | |
| new file mode 100644 | |||
| @@ -0,0 +1,43 @@ | |||
| 1 | +export type PlaceStatus = 'visited' | 'wishlist'; | ||
| 2 | +export type PlaceCategory = 'city' | 'moto-road' | 'nature' | 'culture'; | ||
| 3 | +export type PlacePriority = 'high' | 'medium' | 'low'; | ||
| 4 | + | ||
| 5 | +export interface LatLng { | ||
| 6 | + lat: number; | ||
| 7 | + lng: number; | ||
| 8 | +} | ||
| 9 | + | ||
| 10 | +export interface Place { | ||
| 11 | + readonly id: string; | ||
| 12 | + name: string; | ||
| 13 | + country: string; | ||
| 14 | + category: PlaceCategory; | ||
| 15 | + status: PlaceStatus; | ||
| 16 | + lat: number; | ||
| 17 | + lng: number; | ||
| 18 | + /** Date de visite au format ISO (yyyy-MM-dd), uniquement pour les lieux visités. */ | ||
| 19 | + visitedOn?: string; | ||
| 20 | + /** Note de 1 à 5, facultative. */ | ||
| 21 | + rating?: number; | ||
| 22 | + /** Priorité, uniquement pour les lieux de la liste de souhaits. */ | ||
| 23 | + priority?: PlacePriority; | ||
| 24 | + note?: string; | ||
| 25 | +} | ||
| 26 | + | ||
| 27 | +export type NewPlace = Omit<Place, 'id'>; | ||
| 28 | + | ||
| 29 | +export const CATEGORY_LABELS: Record<PlaceCategory, string> = { | ||
| 30 | + city: 'Ville', | ||
| 31 | + 'moto-road': 'Route moto', | ||
| 32 | + nature: 'Nature', | ||
| 33 | + culture: 'Culture', | ||
| 34 | +}; | ||
| 35 | + | ||
| 36 | +export const PRIORITY_LABELS: Record<PlacePriority, string> = { | ||
| 37 | + high: 'Priorité haute', | ||
| 38 | + medium: 'Priorité moyenne', | ||
| 39 | + low: 'Priorité basse', | ||
| 40 | +}; | ||
| 41 | + | ||
| 42 | +export const CATEGORIES = Object.keys(CATEGORY_LABELS) as PlaceCategory[]; | ||
| 43 | +export const PRIORITIES = Object.keys(PRIORITY_LABELS) as PlacePriority[]; | ||
added
src/app/travel/pages/place-form-page/place-form-page.component.html +144 -0 | new file mode 100644 | ||
| @@ -0,0 +1,144 @@ | ||
| 1 | +<section class="page"> | |
| 2 | + <header class="page__header"> | |
| 3 | + <p class="eyebrow">Nouveau lieu</p> | |
| 4 | + <h1>Ajouter un lieu à votre carnet</h1> | |
| 5 | + </header> | |
| 6 | + | |
| 7 | + <div class="layout"> | |
| 8 | + <form class="card form" [formGroup]="form" (ngSubmit)="submit()" novalidate> | |
| 9 | + <fieldset class="segmented"> | |
| 10 | + <legend>Ce lieu est…</legend> | |
| 11 | + <label> | |
| 12 | + <input type="radio" formControlName="status" value="visited" /> | |
| 13 | + <span>Déjà visité</span> | |
| 14 | + </label> | |
| 15 | + <label> | |
| 16 | + <input type="radio" formControlName="status" value="wishlist" /> | |
| 17 | + <span>Sur ma liste de souhaits</span> | |
| 18 | + </label> | |
| 19 | + </fieldset> | |
| 20 | + | |
| 21 | + <div class="field"> | |
| 22 | + <label for="name">Nom du lieu</label> | |
| 23 | + <input | |
| 24 | + id="name" | |
| 25 | + type="text" | |
| 26 | + formControlName="name" | |
| 27 | + autocomplete="off" | |
| 28 | + placeholder="Annecy, Col de l’Iseran…" | |
| 29 | + [attr.aria-describedby]="errorOf('name') ? 'name-error' : null" | |
| 30 | + /> | |
| 31 | + <p *ngIf="errorOf('name') as message" class="field__error" id="name-error" role="alert">{{ message }}</p> | |
| 32 | + </div> | |
| 33 | + | |
| 34 | + <div class="row"> | |
| 35 | + <div class="field"> | |
| 36 | + <label for="country">Pays</label> | |
| 37 | + <input | |
| 38 | + id="country" | |
| 39 | + type="text" | |
| 40 | + formControlName="country" | |
| 41 | + autocomplete="country-name" | |
| 42 | + [attr.aria-describedby]="errorOf('country') ? 'country-error' : null" | |
| 43 | + /> | |
| 44 | + <p *ngIf="errorOf('country') as message" class="field__error" id="country-error" role="alert">{{ message }}</p> | |
| 45 | + </div> | |
| 46 | + | |
| 47 | + <div class="field"> | |
| 48 | + <label for="category">Type de lieu</label> | |
| 49 | + <select id="category" formControlName="category"> | |
| 50 | + <option *ngFor="let category of categories" [ngValue]="category">{{ categoryLabels[category] }}</option> | |
| 51 | + </select> | |
| 52 | + </div> | |
| 53 | + </div> | |
| 54 | + | |
| 55 | + <div class="row"> | |
| 56 | + <div class="field"> | |
| 57 | + <label for="lat">Latitude</label> | |
| 58 | + <input | |
| 59 | + id="lat" | |
| 60 | + type="number" | |
| 61 | + step="any" | |
| 62 | + inputmode="decimal" | |
| 63 | + formControlName="lat" | |
| 64 | + [attr.aria-describedby]="errorOf('lat') ? 'lat-error' : null" | |
| 65 | + /> | |
| 66 | + <p *ngIf="errorOf('lat') as message" class="field__error" id="lat-error" role="alert">{{ message }}</p> | |
| 67 | + </div> | |
| 68 | + | |
| 69 | + <div class="field"> | |
| 70 | + <label for="lng">Longitude</label> | |
| 71 | + <input | |
| 72 | + id="lng" | |
| 73 | + type="number" | |
| 74 | + step="any" | |
| 75 | + inputmode="decimal" | |
| 76 | + formControlName="lng" | |
| 77 | + [attr.aria-describedby]="errorOf('lng') ? 'lng-error' : null" | |
| 78 | + /> | |
| 79 | + <p *ngIf="errorOf('lng') as message" class="field__error" id="lng-error" role="alert">{{ message }}</p> | |
| 80 | + </div> | |
| 81 | + </div> | |
| 82 | + <p class="hint">Astuce : cliquez sur la carte pour remplir la latitude et la longitude.</p> | |
| 83 | + | |
| 84 | + <ng-container [ngSwitch]="status"> | |
| 85 | + <div class="row" *ngSwitchCase="'visited'"> | |
| 86 | + <div class="field"> | |
| 87 | + <label for="visitedOn">Date de visite</label> | |
| 88 | + <input | |
| 89 | + id="visitedOn" | |
| 90 | + type="date" | |
| 91 | + formControlName="visitedOn" | |
| 92 | + [attr.max]="maxDate" | |
| 93 | + [attr.aria-describedby]="errorOf('visitedOn') ? 'visitedOn-error' : null" | |
| 94 | + /> | |
| 95 | + <p *ngIf="errorOf('visitedOn') as message" class="field__error" id="visitedOn-error" role="alert">{{ message }}</p> | |
| 96 | + </div> | |
| 97 | + | |
| 98 | + <div class="field"> | |
| 99 | + <label for="rating">Note (facultative)</label> | |
| 100 | + <select id="rating" formControlName="rating"> | |
| 101 | + <option [ngValue]="null">Pas de note</option> | |
| 102 | + <option *ngFor="let rating of ratings" [ngValue]="rating">{{ rating }} sur 5</option> | |
| 103 | + </select> | |
| 104 | + </div> | |
| 105 | + </div> | |
| 106 | + <div class="field" *ngSwitchDefault> | |
| 107 | + <label for="priority">Priorité</label> | |
| 108 | + <select id="priority" formControlName="priority"> | |
| 109 | + <option *ngFor="let priority of priorities" [ngValue]="priority">{{ priorityLabels[priority] }}</option> | |
| 110 | + </select> | |
| 111 | + </div> | |
| 112 | + </ng-container> | |
| 113 | + | |
| 114 | + <div class="field"> | |
| 115 | + <label for="note">Note personnelle (facultative)</label> | |
| 116 | + <textarea | |
| 117 | + id="note" | |
| 118 | + rows="3" | |
| 119 | + formControlName="note" | |
| 120 | + [attr.aria-describedby]="errorOf('note') ? 'note-error' : null" | |
| 121 | + ></textarea> | |
| 122 | + <p *ngIf="errorOf('note') as message" class="field__error" id="note-error" role="alert">{{ message }}</p> | |
| 123 | + </div> | |
| 124 | + | |
| 125 | + <div class="actions"> | |
| 126 | + <button type="submit" class="btn btn--primary">Enregistrer</button> | |
| 127 | + <a class="btn" routerLink="/resume">Annuler</a> | |
| 128 | + </div> | |
| 129 | + </form> | |
| 130 | + | |
| 131 | + <section class="card sticky" aria-labelledby="picker-title"> | |
| 132 | + <h2 id="picker-title">Placer le lieu sur la carte</h2> | |
| 133 | + <div class="map-slot"> | |
| 134 | + <app-travel-map | |
| 135 | + [places]="store.places()" | |
| 136 | + [pickable]="true" | |
| 137 | + [picked]="picked" | |
| 138 | + label="Carte pour placer le nouveau lieu" | |
| 139 | + (locationPicked)="onPick($event)" | |
| 140 | + ></app-travel-map> | |
| 141 | + </div> | |
| 142 | + </section> | |
| 143 | + </div> | |
| 144 | +</section> | |
| new file mode 100644 | |||
| @@ -0,0 +1,144 @@ | |||
| 1 | +<section class="page"> | ||
| 2 | + <header class="page__header"> | ||
| 3 | + <p class="eyebrow">Nouveau lieu</p> | ||
| 4 | + <h1>Ajouter un lieu à votre carnet</h1> | ||
| 5 | + </header> | ||
| 6 | + | ||
| 7 | + <div class="layout"> | ||
| 8 | + <form class="card form" [formGroup]="form" (ngSubmit)="submit()" novalidate> | ||
| 9 | + <fieldset class="segmented"> | ||
| 10 | + <legend>Ce lieu est…</legend> | ||
| 11 | + <label> | ||
| 12 | + <input type="radio" formControlName="status" value="visited" /> | ||
| 13 | + <span>Déjà visité</span> | ||
| 14 | + </label> | ||
| 15 | + <label> | ||
| 16 | + <input type="radio" formControlName="status" value="wishlist" /> | ||
| 17 | + <span>Sur ma liste de souhaits</span> | ||
| 18 | + </label> | ||
| 19 | + </fieldset> | ||
| 20 | + | ||
| 21 | + <div class="field"> | ||
| 22 | + <label for="name">Nom du lieu</label> | ||
| 23 | + <input | ||
| 24 | + id="name" | ||
| 25 | + type="text" | ||
| 26 | + formControlName="name" | ||
| 27 | + autocomplete="off" | ||
| 28 | + placeholder="Annecy, Col de l’Iseran…" | ||
| 29 | + [attr.aria-describedby]="errorOf('name') ? 'name-error' : null" | ||
| 30 | + /> | ||
| 31 | + <p *ngIf="errorOf('name') as message" class="field__error" id="name-error" role="alert">{{ message }}</p> | ||
| 32 | + </div> | ||
| 33 | + | ||
| 34 | + <div class="row"> | ||
| 35 | + <div class="field"> | ||
| 36 | + <label for="country">Pays</label> | ||
| 37 | + <input | ||
| 38 | + id="country" | ||
| 39 | + type="text" | ||
| 40 | + formControlName="country" | ||
| 41 | + autocomplete="country-name" | ||
| 42 | + [attr.aria-describedby]="errorOf('country') ? 'country-error' : null" | ||
| 43 | + /> | ||
| 44 | + <p *ngIf="errorOf('country') as message" class="field__error" id="country-error" role="alert">{{ message }}</p> | ||
| 45 | + </div> | ||
| 46 | + | ||
| 47 | + <div class="field"> | ||
| 48 | + <label for="category">Type de lieu</label> | ||
| 49 | + <select id="category" formControlName="category"> | ||
| 50 | + <option *ngFor="let category of categories" [ngValue]="category">{{ categoryLabels[category] }}</option> | ||
| 51 | + </select> | ||
| 52 | + </div> | ||
| 53 | + </div> | ||
| 54 | + | ||
| 55 | + <div class="row"> | ||
| 56 | + <div class="field"> | ||
| 57 | + <label for="lat">Latitude</label> | ||
| 58 | + <input | ||
| 59 | + id="lat" | ||
| 60 | + type="number" | ||
| 61 | + step="any" | ||
| 62 | + inputmode="decimal" | ||
| 63 | + formControlName="lat" | ||
| 64 | + [attr.aria-describedby]="errorOf('lat') ? 'lat-error' : null" | ||
| 65 | + /> | ||
| 66 | + <p *ngIf="errorOf('lat') as message" class="field__error" id="lat-error" role="alert">{{ message }}</p> | ||
| 67 | + </div> | ||
| 68 | + | ||
| 69 | + <div class="field"> | ||
| 70 | + <label for="lng">Longitude</label> | ||
| 71 | + <input | ||
| 72 | + id="lng" | ||
| 73 | + type="number" | ||
| 74 | + step="any" | ||
| 75 | + inputmode="decimal" | ||
| 76 | + formControlName="lng" | ||
| 77 | + [attr.aria-describedby]="errorOf('lng') ? 'lng-error' : null" | ||
| 78 | + /> | ||
| 79 | + <p *ngIf="errorOf('lng') as message" class="field__error" id="lng-error" role="alert">{{ message }}</p> | ||
| 80 | + </div> | ||
| 81 | + </div> | ||
| 82 | + <p class="hint">Astuce : cliquez sur la carte pour remplir la latitude et la longitude.</p> | ||
| 83 | + | ||
| 84 | + <ng-container [ngSwitch]="status"> | ||
| 85 | + <div class="row" *ngSwitchCase="'visited'"> | ||
| 86 | + <div class="field"> | ||
| 87 | + <label for="visitedOn">Date de visite</label> | ||
| 88 | + <input | ||
| 89 | + id="visitedOn" | ||
| 90 | + type="date" | ||
| 91 | + formControlName="visitedOn" | ||
| 92 | + [attr.max]="maxDate" | ||
| 93 | + [attr.aria-describedby]="errorOf('visitedOn') ? 'visitedOn-error' : null" | ||
| 94 | + /> | ||
| 95 | + <p *ngIf="errorOf('visitedOn') as message" class="field__error" id="visitedOn-error" role="alert">{{ message }}</p> | ||
| 96 | + </div> | ||
| 97 | + | ||
| 98 | + <div class="field"> | ||
| 99 | + <label for="rating">Note (facultative)</label> | ||
| 100 | + <select id="rating" formControlName="rating"> | ||
| 101 | + <option [ngValue]="null">Pas de note</option> | ||
| 102 | + <option *ngFor="let rating of ratings" [ngValue]="rating">{{ rating }} sur 5</option> | ||
| 103 | + </select> | ||
| 104 | + </div> | ||
| 105 | + </div> | ||
| 106 | + <div class="field" *ngSwitchDefault> | ||
| 107 | + <label for="priority">Priorité</label> | ||
| 108 | + <select id="priority" formControlName="priority"> | ||
| 109 | + <option *ngFor="let priority of priorities" [ngValue]="priority">{{ priorityLabels[priority] }}</option> | ||
| 110 | + </select> | ||
| 111 | + </div> | ||
| 112 | + </ng-container> | ||
| 113 | + | ||
| 114 | + <div class="field"> | ||
| 115 | + <label for="note">Note personnelle (facultative)</label> | ||
| 116 | + <textarea | ||
| 117 | + id="note" | ||
| 118 | + rows="3" | ||
| 119 | + formControlName="note" | ||
| 120 | + [attr.aria-describedby]="errorOf('note') ? 'note-error' : null" | ||
| 121 | + ></textarea> | ||
| 122 | + <p *ngIf="errorOf('note') as message" class="field__error" id="note-error" role="alert">{{ message }}</p> | ||
| 123 | + </div> | ||
| 124 | + | ||
| 125 | + <div class="actions"> | ||
| 126 | + <button type="submit" class="btn btn--primary">Enregistrer</button> | ||
| 127 | + <a class="btn" routerLink="/resume">Annuler</a> | ||
| 128 | + </div> | ||
| 129 | + </form> | ||
| 130 | + | ||
| 131 | + <section class="card sticky" aria-labelledby="picker-title"> | ||
| 132 | + <h2 id="picker-title">Placer le lieu sur la carte</h2> | ||
| 133 | + <div class="map-slot"> | ||
| 134 | + <app-travel-map | ||
| 135 | + [places]="store.places()" | ||
| 136 | + [pickable]="true" | ||
| 137 | + [picked]="picked" | ||
| 138 | + label="Carte pour placer le nouveau lieu" | ||
| 139 | + (locationPicked)="onPick($event)" | ||
| 140 | + ></app-travel-map> | ||
| 141 | + </div> | ||
| 142 | + </section> | ||
| 143 | + </div> | ||
| 144 | +</section> | ||
added
src/app/travel/pages/place-form-page/place-form-page.component.spec.ts +126 -0 | new file mode 100644 | ||
| @@ -0,0 +1,126 @@ | ||
| 1 | +import { ComponentFixture, TestBed } from '@angular/core/testing'; | |
| 2 | +import { Router, provideRouter } from '@angular/router'; | |
| 3 | +import { memoryStorage } from '../../testing/memory-storage'; | |
| 4 | +import { STORAGE_KEY, TRAVEL_STORAGE, TravelStore } from '../../services/travel-store.service'; | |
| 5 | +import { todayIso } from '../../utils/date'; | |
| 6 | +import { PlaceFormPageComponent } from './place-form-page.component'; | |
| 7 | + | |
| 8 | +describe('PlaceFormPageComponent', () => { | |
| 9 | + let fixture: ComponentFixture<PlaceFormPageComponent>; | |
| 10 | + let root: HTMLElement; | |
| 11 | + let store: TravelStore; | |
| 12 | + let navigate: jasmine.Spy; | |
| 13 | + | |
| 14 | + beforeEach(async () => { | |
| 15 | + await TestBed.configureTestingModule({ | |
| 16 | + imports: [PlaceFormPageComponent], | |
| 17 | + providers: [ | |
| 18 | + provideRouter([]), | |
| 19 | + { provide: TRAVEL_STORAGE, useValue: memoryStorage({ [STORAGE_KEY]: '[]' }) }, | |
| 20 | + ], | |
| 21 | + }).compileComponents(); | |
| 22 | + | |
| 23 | + store = TestBed.inject(TravelStore); | |
| 24 | + navigate = spyOn(TestBed.inject(Router), 'navigate').and.resolveTo(true); | |
| 25 | + | |
| 26 | + fixture = TestBed.createComponent(PlaceFormPageComponent); | |
| 27 | + root = fixture.nativeElement as HTMLElement; | |
| 28 | + fixture.detectChanges(); | |
| 29 | + }); | |
| 30 | + | |
| 31 | + function field<T extends HTMLElement>(id: string): T { | |
| 32 | + return root.querySelector<T>(`#${id}`) as T; | |
| 33 | + } | |
| 34 | + | |
| 35 | + function type(id: string, value: string): void { | |
| 36 | + const element = field<HTMLInputElement | HTMLTextAreaElement>(id); | |
| 37 | + element.value = value; | |
| 38 | + element.dispatchEvent(new Event('input')); | |
| 39 | + element.dispatchEvent(new Event('blur')); | |
| 40 | + fixture.detectChanges(); | |
| 41 | + } | |
| 42 | + | |
| 43 | + function choose(status: 'visited' | 'wishlist'): void { | |
| 44 | + const radio = root.querySelector<HTMLInputElement>(`input[type="radio"][value="${status}"]`) as HTMLInputElement; | |
| 45 | + radio.click(); | |
| 46 | + fixture.detectChanges(); | |
| 47 | + } | |
| 48 | + | |
| 49 | + function submit(): void { | |
| 50 | + root.querySelector<HTMLButtonElement>('button[type="submit"]')?.click(); | |
| 51 | + fixture.detectChanges(); | |
| 52 | + } | |
| 53 | + | |
| 54 | + function fillCommonFields(): void { | |
| 55 | + type('name', ' Annecy '); | |
| 56 | + type('country', 'France'); | |
| 57 | + type('lat', '45.8992'); | |
| 58 | + type('lng', '6.1294'); | |
| 59 | + } | |
| 60 | + | |
| 61 | + it('affiche les champs d’un lieu visité par défaut', () => { | |
| 62 | + expect(field('visitedOn')).not.toBeNull(); | |
| 63 | + expect(root.querySelector('#priority')).toBeNull(); | |
| 64 | + }); | |
| 65 | + | |
| 66 | + it('refuse un formulaire vide et affiche les erreurs', () => { | |
| 67 | + submit(); | |
| 68 | + | |
| 69 | + expect(store.places().length).toBe(0); | |
| 70 | + expect(navigate).not.toHaveBeenCalled(); | |
| 71 | + expect(root.querySelectorAll('.field__error').length).toBeGreaterThanOrEqual(4); | |
| 72 | + }); | |
| 73 | + | |
| 74 | + it('refuse une latitude hors limites', () => { | |
| 75 | + fillCommonFields(); | |
| 76 | + type('lat', '120'); | |
| 77 | + type('visitedOn', '2024-08-01'); | |
| 78 | + submit(); | |
| 79 | + | |
| 80 | + expect(root.querySelector('#lat-error')?.textContent).toContain('-90 et 90'); | |
| 81 | + expect(store.places().length).toBe(0); | |
| 82 | + }); | |
| 83 | + | |
| 84 | + it('refuse une date de visite dans le futur', () => { | |
| 85 | + fillCommonFields(); | |
| 86 | + type('visitedOn', '2999-01-01'); | |
| 87 | + submit(); | |
| 88 | + | |
| 89 | + expect(root.querySelector('#visitedOn-error')?.textContent).toContain('futur'); | |
| 90 | + expect(store.places().length).toBe(0); | |
| 91 | + }); | |
| 92 | + | |
| 93 | + it('enregistre un lieu visité puis ouvre le résumé', () => { | |
| 94 | + fillCommonFields(); | |
| 95 | + type('visitedOn', '2024-08-01'); | |
| 96 | + submit(); | |
| 97 | + | |
| 98 | + expect(store.places()).toEqual([ | |
| 99 | + jasmine.objectContaining({ | |
| 100 | + name: 'Annecy', | |
| 101 | + country: 'France', | |
| 102 | + status: 'visited', | |
| 103 | + lat: 45.8992, | |
| 104 | + lng: 6.1294, | |
| 105 | + visitedOn: '2024-08-01', | |
| 106 | + }), | |
| 107 | + ]); | |
| 108 | + expect(navigate).toHaveBeenCalledWith(['/resume']); | |
| 109 | + }); | |
| 110 | + | |
| 111 | + it('enregistre un souhait sans demander de date de visite', () => { | |
| 112 | + choose('wishlist'); | |
| 113 | + fillCommonFields(); | |
| 114 | + submit(); | |
| 115 | + | |
| 116 | + expect(root.querySelector('#visitedOn')).toBeNull(); | |
| 117 | + expect(store.wishlist()).toEqual([ | |
| 118 | + jasmine.objectContaining({ name: 'Annecy', status: 'wishlist', priority: 'medium', visitedOn: undefined }), | |
| 119 | + ]); | |
| 120 | + expect(navigate).toHaveBeenCalledWith(['/souhaits']); | |
| 121 | + }); | |
| 122 | + | |
| 123 | + it('limite la date de visite à aujourd’hui', () => { | |
| 124 | + expect(field('visitedOn').getAttribute('max')).toBe(todayIso()); | |
| 125 | + }); | |
| 126 | +}); | |
| new file mode 100644 | |||
| @@ -0,0 +1,126 @@ | |||
| 1 | +import { ComponentFixture, TestBed } from '@angular/core/testing'; | ||
| 2 | +import { Router, provideRouter } from '@angular/router'; | ||
| 3 | +import { memoryStorage } from '../../testing/memory-storage'; | ||
| 4 | +import { STORAGE_KEY, TRAVEL_STORAGE, TravelStore } from '../../services/travel-store.service'; | ||
| 5 | +import { todayIso } from '../../utils/date'; | ||
| 6 | +import { PlaceFormPageComponent } from './place-form-page.component'; | ||
| 7 | + | ||
| 8 | +describe('PlaceFormPageComponent', () => { | ||
| 9 | + let fixture: ComponentFixture<PlaceFormPageComponent>; | ||
| 10 | + let root: HTMLElement; | ||
| 11 | + let store: TravelStore; | ||
| 12 | + let navigate: jasmine.Spy; | ||
| 13 | + | ||
| 14 | + beforeEach(async () => { | ||
| 15 | + await TestBed.configureTestingModule({ | ||
| 16 | + imports: [PlaceFormPageComponent], | ||
| 17 | + providers: [ | ||
| 18 | + provideRouter([]), | ||
| 19 | + { provide: TRAVEL_STORAGE, useValue: memoryStorage({ [STORAGE_KEY]: '[]' }) }, | ||
| 20 | + ], | ||
| 21 | + }).compileComponents(); | ||
| 22 | + | ||
| 23 | + store = TestBed.inject(TravelStore); | ||
| 24 | + navigate = spyOn(TestBed.inject(Router), 'navigate').and.resolveTo(true); | ||
| 25 | + | ||
| 26 | + fixture = TestBed.createComponent(PlaceFormPageComponent); | ||
| 27 | + root = fixture.nativeElement as HTMLElement; | ||
| 28 | + fixture.detectChanges(); | ||
| 29 | + }); | ||
| 30 | + | ||
| 31 | + function field<T extends HTMLElement>(id: string): T { | ||
| 32 | + return root.querySelector<T>(`#${id}`) as T; | ||
| 33 | + } | ||
| 34 | + | ||
| 35 | + function type(id: string, value: string): void { | ||
| 36 | + const element = field<HTMLInputElement | HTMLTextAreaElement>(id); | ||
| 37 | + element.value = value; | ||
| 38 | + element.dispatchEvent(new Event('input')); | ||
| 39 | + element.dispatchEvent(new Event('blur')); | ||
| 40 | + fixture.detectChanges(); | ||
| 41 | + } | ||
| 42 | + | ||
| 43 | + function choose(status: 'visited' | 'wishlist'): void { | ||
| 44 | + const radio = root.querySelector<HTMLInputElement>(`input[type="radio"][value="${status}"]`) as HTMLInputElement; | ||
| 45 | + radio.click(); | ||
| 46 | + fixture.detectChanges(); | ||
| 47 | + } | ||
| 48 | + | ||
| 49 | + function submit(): void { | ||
| 50 | + root.querySelector<HTMLButtonElement>('button[type="submit"]')?.click(); | ||
| 51 | + fixture.detectChanges(); | ||
| 52 | + } | ||
| 53 | + | ||
| 54 | + function fillCommonFields(): void { | ||
| 55 | + type('name', ' Annecy '); | ||
| 56 | + type('country', 'France'); | ||
| 57 | + type('lat', '45.8992'); | ||
| 58 | + type('lng', '6.1294'); | ||
| 59 | + } | ||
| 60 | + | ||
| 61 | + it('affiche les champs d’un lieu visité par défaut', () => { | ||
| 62 | + expect(field('visitedOn')).not.toBeNull(); | ||
| 63 | + expect(root.querySelector('#priority')).toBeNull(); | ||
| 64 | + }); | ||
| 65 | + | ||
| 66 | + it('refuse un formulaire vide et affiche les erreurs', () => { | ||
| 67 | + submit(); | ||
| 68 | + | ||
| 69 | + expect(store.places().length).toBe(0); | ||
| 70 | + expect(navigate).not.toHaveBeenCalled(); | ||
| 71 | + expect(root.querySelectorAll('.field__error').length).toBeGreaterThanOrEqual(4); | ||
| 72 | + }); | ||
| 73 | + | ||
| 74 | + it('refuse une latitude hors limites', () => { | ||
| 75 | + fillCommonFields(); | ||
| 76 | + type('lat', '120'); | ||
| 77 | + type('visitedOn', '2024-08-01'); | ||
| 78 | + submit(); | ||
| 79 | + | ||
| 80 | + expect(root.querySelector('#lat-error')?.textContent).toContain('-90 et 90'); | ||
| 81 | + expect(store.places().length).toBe(0); | ||
| 82 | + }); | ||
| 83 | + | ||
| 84 | + it('refuse une date de visite dans le futur', () => { | ||
| 85 | + fillCommonFields(); | ||
| 86 | + type('visitedOn', '2999-01-01'); | ||
| 87 | + submit(); | ||
| 88 | + | ||
| 89 | + expect(root.querySelector('#visitedOn-error')?.textContent).toContain('futur'); | ||
| 90 | + expect(store.places().length).toBe(0); | ||
| 91 | + }); | ||
| 92 | + | ||
| 93 | + it('enregistre un lieu visité puis ouvre le résumé', () => { | ||
| 94 | + fillCommonFields(); | ||
| 95 | + type('visitedOn', '2024-08-01'); | ||
| 96 | + submit(); | ||
| 97 | + | ||
| 98 | + expect(store.places()).toEqual([ | ||
| 99 | + jasmine.objectContaining({ | ||
| 100 | + name: 'Annecy', | ||
| 101 | + country: 'France', | ||
| 102 | + status: 'visited', | ||
| 103 | + lat: 45.8992, | ||
| 104 | + lng: 6.1294, | ||
| 105 | + visitedOn: '2024-08-01', | ||
| 106 | + }), | ||
| 107 | + ]); | ||
| 108 | + expect(navigate).toHaveBeenCalledWith(['/resume']); | ||
| 109 | + }); | ||
| 110 | + | ||
| 111 | + it('enregistre un souhait sans demander de date de visite', () => { | ||
| 112 | + choose('wishlist'); | ||
| 113 | + fillCommonFields(); | ||
| 114 | + submit(); | ||
| 115 | + | ||
| 116 | + expect(root.querySelector('#visitedOn')).toBeNull(); | ||
| 117 | + expect(store.wishlist()).toEqual([ | ||
| 118 | + jasmine.objectContaining({ name: 'Annecy', status: 'wishlist', priority: 'medium', visitedOn: undefined }), | ||
| 119 | + ]); | ||
| 120 | + expect(navigate).toHaveBeenCalledWith(['/souhaits']); | ||
| 121 | + }); | ||
| 122 | + | ||
| 123 | + it('limite la date de visite à aujourd’hui', () => { | ||
| 124 | + expect(field('visitedOn').getAttribute('max')).toBe(todayIso()); | ||
| 125 | + }); | ||
| 126 | +}); | ||
added
src/app/travel/pages/place-form-page/place-form-page.component.ts +154 -0 | new file mode 100644 | ||
| @@ -0,0 +1,154 @@ | ||
| 1 | +import { CommonModule } from '@angular/common'; | |
| 2 | +import { Component, OnInit } from '@angular/core'; | |
| 3 | +import { AbstractControl, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; | |
| 4 | +import { Router, RouterLink } from '@angular/router'; | |
| 5 | +import { TravelMapComponent } from '../../components/travel-map/travel-map.component'; | |
| 6 | +import { | |
| 7 | + CATEGORIES, | |
| 8 | + CATEGORY_LABELS, | |
| 9 | + LatLng, | |
| 10 | + PRIORITIES, | |
| 11 | + PRIORITY_LABELS, | |
| 12 | + PlaceCategory, | |
| 13 | + PlacePriority, | |
| 14 | + PlaceStatus, | |
| 15 | +} from '../../models/place.model'; | |
| 16 | +import { TravelStore } from '../../services/travel-store.service'; | |
| 17 | +import { todayIso } from '../../utils/date'; | |
| 18 | +import { notInFuture } from '../../utils/not-in-future.validator'; | |
| 19 | + | |
| 20 | +type TextField = 'name' | 'country' | 'lat' | 'lng' | 'visitedOn' | 'note'; | |
| 21 | + | |
| 22 | +@Component({ | |
| 23 | + selector: 'app-place-form-page', | |
| 24 | + standalone: true, | |
| 25 | + imports: [CommonModule, ReactiveFormsModule, RouterLink, TravelMapComponent], | |
| 26 | + templateUrl: './place-form-page.component.html', | |
| 27 | +}) | |
| 28 | +export class PlaceFormPageComponent implements OnInit { | |
| 29 | + categories = CATEGORIES; | |
| 30 | + categoryLabels = CATEGORY_LABELS; | |
| 31 | + priorities = PRIORITIES; | |
| 32 | + priorityLabels = PRIORITY_LABELS; | |
| 33 | + ratings = [1, 2, 3, 4, 5]; | |
| 34 | + maxDate = todayIso(); | |
| 35 | + | |
| 36 | + status: PlaceStatus = 'visited'; | |
| 37 | + /** Point à afficher sur la carte, tant que la latitude et la longitude saisies sont valides. */ | |
| 38 | + picked: LatLng | null = null; | |
| 39 | + | |
| 40 | + form = new FormGroup({ | |
| 41 | + status: new FormControl<PlaceStatus>('visited', { nonNullable: true }), | |
| 42 | + name: new FormControl('', { | |
| 43 | + nonNullable: true, | |
| 44 | + validators: [Validators.required, Validators.minLength(2), Validators.maxLength(80)], | |
| 45 | + }), | |
| 46 | + country: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.maxLength(60)] }), | |
| 47 | + category: new FormControl<PlaceCategory>('city', { nonNullable: true }), | |
| 48 | + lat: new FormControl<number | null>(null, [Validators.required, Validators.min(-90), Validators.max(90)]), | |
| 49 | + lng: new FormControl<number | null>(null, [Validators.required, Validators.min(-180), Validators.max(180)]), | |
| 50 | + visitedOn: new FormControl('', { nonNullable: true, validators: [Validators.required, notInFuture()] }), | |
| 51 | + rating: new FormControl<number | null>(null), | |
| 52 | + priority: new FormControl<PlacePriority>('medium', { nonNullable: true }), | |
| 53 | + note: new FormControl('', { nonNullable: true, validators: [Validators.maxLength(280)] }), | |
| 54 | + }); | |
| 55 | + | |
| 56 | + constructor( | |
| 57 | + public store: TravelStore, | |
| 58 | + private router: Router, | |
| 59 | + ) {} | |
| 60 | + | |
| 61 | + ngOnInit(): void { | |
| 62 | + this.syncStatusFields(this.form.controls.status.value); | |
| 63 | + | |
| 64 | + this.form.controls.status.valueChanges.subscribe((status) => { | |
| 65 | + this.status = status; | |
| 66 | + this.syncStatusFields(status); | |
| 67 | + }); | |
| 68 | + | |
| 69 | + this.form.valueChanges.subscribe(({ lat, lng }) => { | |
| 70 | + this.picked = lat != null && lng != null && Math.abs(lat) <= 90 && Math.abs(lng) <= 180 ? { lat, lng } : null; | |
| 71 | + }); | |
| 72 | + } | |
| 73 | + | |
| 74 | + onPick(point: LatLng): void { | |
| 75 | + this.form.patchValue(point); | |
| 76 | + this.form.controls.lat.markAsDirty(); | |
| 77 | + this.form.controls.lng.markAsDirty(); | |
| 78 | + } | |
| 79 | + | |
| 80 | + submit(): void { | |
| 81 | + if (this.form.invalid) { | |
| 82 | + this.form.markAllAsTouched(); | |
| 83 | + return; | |
| 84 | + } | |
| 85 | + | |
| 86 | + const value = this.form.getRawValue(); | |
| 87 | + if (value.lat === null || value.lng === null) { | |
| 88 | + return; | |
| 89 | + } | |
| 90 | + | |
| 91 | + const visited = value.status === 'visited'; | |
| 92 | + this.store.add({ | |
| 93 | + name: value.name.trim(), | |
| 94 | + country: value.country.trim(), | |
| 95 | + category: value.category, | |
| 96 | + status: value.status, | |
| 97 | + lat: value.lat, | |
| 98 | + lng: value.lng, | |
| 99 | + visitedOn: visited ? value.visitedOn : undefined, | |
| 100 | + rating: visited ? (value.rating ?? undefined) : undefined, | |
| 101 | + priority: visited ? undefined : value.priority, | |
| 102 | + note: value.note.trim() || undefined, | |
| 103 | + }); | |
| 104 | + | |
| 105 | + this.router.navigate([visited ? '/resume' : '/souhaits']); | |
| 106 | + } | |
| 107 | + | |
| 108 | + /** Message d'erreur à afficher pour un champ, ou `null` tant qu'il n'a pas été touché ou s'il est valide. */ | |
| 109 | + errorOf(field: TextField): string | null { | |
| 110 | + const control = this.form.controls[field]; | |
| 111 | + if (!control.invalid || !(control.touched || control.dirty)) { | |
| 112 | + return null; | |
| 113 | + } | |
| 114 | + | |
| 115 | + const errors = control.errors || {}; | |
| 116 | + if (errors['required']) { | |
| 117 | + return field === 'lat' || field === 'lng' | |
| 118 | + ? 'Saisissez une valeur ou cliquez sur la carte.' | |
| 119 | + : 'Ce champ est obligatoire.'; | |
| 120 | + } | |
| 121 | + if (errors['minlength']) { | |
| 122 | + return `Au moins ${errors['minlength'].requiredLength} caractères.`; | |
| 123 | + } | |
| 124 | + if (errors['maxlength']) { | |
| 125 | + return `Au plus ${errors['maxlength'].requiredLength} caractères.`; | |
| 126 | + } | |
| 127 | + if (errors['min'] || errors['max']) { | |
| 128 | + return field === 'lat' | |
| 129 | + ? 'La latitude doit être comprise entre -90 et 90.' | |
| 130 | + : 'La longitude doit être comprise entre -180 et 180.'; | |
| 131 | + } | |
| 132 | + if (errors['future']) { | |
| 133 | + return 'La date ne peut pas être dans le futur.'; | |
| 134 | + } | |
| 135 | + return 'Valeur invalide.'; | |
| 136 | + } | |
| 137 | + | |
| 138 | + /** Les champs qui ne concernent pas le statut choisi sont désactivés : ils sortent de la validation. */ | |
| 139 | + private syncStatusFields(status: PlaceStatus): void { | |
| 140 | + const { visitedOn, rating, priority } = this.form.controls; | |
| 141 | + const visited = status === 'visited'; | |
| 142 | + setEnabled(visitedOn, visited); | |
| 143 | + setEnabled(rating, visited); | |
| 144 | + setEnabled(priority, !visited); | |
| 145 | + } | |
| 146 | +} | |
| 147 | + | |
| 148 | +function setEnabled(control: AbstractControl, enabled: boolean): void { | |
| 149 | + if (enabled) { | |
| 150 | + control.enable({ emitEvent: false }); | |
| 151 | + } else { | |
| 152 | + control.disable({ emitEvent: false }); | |
| 153 | + } | |
| 154 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,154 @@ | |||
| 1 | +import { CommonModule } from '@angular/common'; | ||
| 2 | +import { Component, OnInit } from '@angular/core'; | ||
| 3 | +import { AbstractControl, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; | ||
| 4 | +import { Router, RouterLink } from '@angular/router'; | ||
| 5 | +import { TravelMapComponent } from '../../components/travel-map/travel-map.component'; | ||
| 6 | +import { | ||
| 7 | + CATEGORIES, | ||
| 8 | + CATEGORY_LABELS, | ||
| 9 | + LatLng, | ||
| 10 | + PRIORITIES, | ||
| 11 | + PRIORITY_LABELS, | ||
| 12 | + PlaceCategory, | ||
| 13 | + PlacePriority, | ||
| 14 | + PlaceStatus, | ||
| 15 | +} from '../../models/place.model'; | ||
| 16 | +import { TravelStore } from '../../services/travel-store.service'; | ||
| 17 | +import { todayIso } from '../../utils/date'; | ||
| 18 | +import { notInFuture } from '../../utils/not-in-future.validator'; | ||
| 19 | + | ||
| 20 | +type TextField = 'name' | 'country' | 'lat' | 'lng' | 'visitedOn' | 'note'; | ||
| 21 | + | ||
| 22 | +@Component({ | ||
| 23 | + selector: 'app-place-form-page', | ||
| 24 | + standalone: true, | ||
| 25 | + imports: [CommonModule, ReactiveFormsModule, RouterLink, TravelMapComponent], | ||
| 26 | + templateUrl: './place-form-page.component.html', | ||
| 27 | +}) | ||
| 28 | +export class PlaceFormPageComponent implements OnInit { | ||
| 29 | + categories = CATEGORIES; | ||
| 30 | + categoryLabels = CATEGORY_LABELS; | ||
| 31 | + priorities = PRIORITIES; | ||
| 32 | + priorityLabels = PRIORITY_LABELS; | ||
| 33 | + ratings = [1, 2, 3, 4, 5]; | ||
| 34 | + maxDate = todayIso(); | ||
| 35 | + | ||
| 36 | + status: PlaceStatus = 'visited'; | ||
| 37 | + /** Point à afficher sur la carte, tant que la latitude et la longitude saisies sont valides. */ | ||
| 38 | + picked: LatLng | null = null; | ||
| 39 | + | ||
| 40 | + form = new FormGroup({ | ||
| 41 | + status: new FormControl<PlaceStatus>('visited', { nonNullable: true }), | ||
| 42 | + name: new FormControl('', { | ||
| 43 | + nonNullable: true, | ||
| 44 | + validators: [Validators.required, Validators.minLength(2), Validators.maxLength(80)], | ||
| 45 | + }), | ||
| 46 | + country: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.maxLength(60)] }), | ||
| 47 | + category: new FormControl<PlaceCategory>('city', { nonNullable: true }), | ||
| 48 | + lat: new FormControl<number | null>(null, [Validators.required, Validators.min(-90), Validators.max(90)]), | ||
| 49 | + lng: new FormControl<number | null>(null, [Validators.required, Validators.min(-180), Validators.max(180)]), | ||
| 50 | + visitedOn: new FormControl('', { nonNullable: true, validators: [Validators.required, notInFuture()] }), | ||
| 51 | + rating: new FormControl<number | null>(null), | ||
| 52 | + priority: new FormControl<PlacePriority>('medium', { nonNullable: true }), | ||
| 53 | + note: new FormControl('', { nonNullable: true, validators: [Validators.maxLength(280)] }), | ||
| 54 | + }); | ||
| 55 | + | ||
| 56 | + constructor( | ||
| 57 | + public store: TravelStore, | ||
| 58 | + private router: Router, | ||
| 59 | + ) {} | ||
| 60 | + | ||
| 61 | + ngOnInit(): void { | ||
| 62 | + this.syncStatusFields(this.form.controls.status.value); | ||
| 63 | + | ||
| 64 | + this.form.controls.status.valueChanges.subscribe((status) => { | ||
| 65 | + this.status = status; | ||
| 66 | + this.syncStatusFields(status); | ||
| 67 | + }); | ||
| 68 | + | ||
| 69 | + this.form.valueChanges.subscribe(({ lat, lng }) => { | ||
| 70 | + this.picked = lat != null && lng != null && Math.abs(lat) <= 90 && Math.abs(lng) <= 180 ? { lat, lng } : null; | ||
| 71 | + }); | ||
| 72 | + } | ||
| 73 | + | ||
| 74 | + onPick(point: LatLng): void { | ||
| 75 | + this.form.patchValue(point); | ||
| 76 | + this.form.controls.lat.markAsDirty(); | ||
| 77 | + this.form.controls.lng.markAsDirty(); | ||
| 78 | + } | ||
| 79 | + | ||
| 80 | + submit(): void { | ||
| 81 | + if (this.form.invalid) { | ||
| 82 | + this.form.markAllAsTouched(); | ||
| 83 | + return; | ||
| 84 | + } | ||
| 85 | + | ||
| 86 | + const value = this.form.getRawValue(); | ||
| 87 | + if (value.lat === null || value.lng === null) { | ||
| 88 | + return; | ||
| 89 | + } | ||
| 90 | + | ||
| 91 | + const visited = value.status === 'visited'; | ||
| 92 | + this.store.add({ | ||
| 93 | + name: value.name.trim(), | ||
| 94 | + country: value.country.trim(), | ||
| 95 | + category: value.category, | ||
| 96 | + status: value.status, | ||
| 97 | + lat: value.lat, | ||
| 98 | + lng: value.lng, | ||
| 99 | + visitedOn: visited ? value.visitedOn : undefined, | ||
| 100 | + rating: visited ? (value.rating ?? undefined) : undefined, | ||
| 101 | + priority: visited ? undefined : value.priority, | ||
| 102 | + note: value.note.trim() || undefined, | ||
| 103 | + }); | ||
| 104 | + | ||
| 105 | + this.router.navigate([visited ? '/resume' : '/souhaits']); | ||
| 106 | + } | ||
| 107 | + | ||
| 108 | + /** Message d'erreur à afficher pour un champ, ou `null` tant qu'il n'a pas été touché ou s'il est valide. */ | ||
| 109 | + errorOf(field: TextField): string | null { | ||
| 110 | + const control = this.form.controls[field]; | ||
| 111 | + if (!control.invalid || !(control.touched || control.dirty)) { | ||
| 112 | + return null; | ||
| 113 | + } | ||
| 114 | + | ||
| 115 | + const errors = control.errors || {}; | ||
| 116 | + if (errors['required']) { | ||
| 117 | + return field === 'lat' || field === 'lng' | ||
| 118 | + ? 'Saisissez une valeur ou cliquez sur la carte.' | ||
| 119 | + : 'Ce champ est obligatoire.'; | ||
| 120 | + } | ||
| 121 | + if (errors['minlength']) { | ||
| 122 | + return `Au moins ${errors['minlength'].requiredLength} caractères.`; | ||
| 123 | + } | ||
| 124 | + if (errors['maxlength']) { | ||
| 125 | + return `Au plus ${errors['maxlength'].requiredLength} caractères.`; | ||
| 126 | + } | ||
| 127 | + if (errors['min'] || errors['max']) { | ||
| 128 | + return field === 'lat' | ||
| 129 | + ? 'La latitude doit être comprise entre -90 et 90.' | ||
| 130 | + : 'La longitude doit être comprise entre -180 et 180.'; | ||
| 131 | + } | ||
| 132 | + if (errors['future']) { | ||
| 133 | + return 'La date ne peut pas être dans le futur.'; | ||
| 134 | + } | ||
| 135 | + return 'Valeur invalide.'; | ||
| 136 | + } | ||
| 137 | + | ||
| 138 | + /** Les champs qui ne concernent pas le statut choisi sont désactivés : ils sortent de la validation. */ | ||
| 139 | + private syncStatusFields(status: PlaceStatus): void { | ||
| 140 | + const { visitedOn, rating, priority } = this.form.controls; | ||
| 141 | + const visited = status === 'visited'; | ||
| 142 | + setEnabled(visitedOn, visited); | ||
| 143 | + setEnabled(rating, visited); | ||
| 144 | + setEnabled(priority, !visited); | ||
| 145 | + } | ||
| 146 | +} | ||
| 147 | + | ||
| 148 | +function setEnabled(control: AbstractControl, enabled: boolean): void { | ||
| 149 | + if (enabled) { | ||
| 150 | + control.enable({ emitEvent: false }); | ||
| 151 | + } else { | ||
| 152 | + control.disable({ emitEvent: false }); | ||
| 153 | + } | ||
| 154 | +} | ||
added
src/app/travel/pages/summary-page/summary-page.component.html +65 -0 | new file mode 100644 | ||
| @@ -0,0 +1,65 @@ | ||
| 1 | +<section class="page"> | |
| 2 | + <header class="page__header"> | |
| 3 | + <p class="eyebrow">Carnet de voyage</p> | |
| 4 | + <h1>Votre monde en un coup d’œil</h1> | |
| 5 | + </header> | |
| 6 | + | |
| 7 | + <ul class="stat-grid" aria-label="Statistiques de voyage"> | |
| 8 | + <li class="card stat"> | |
| 9 | + <span class="stat__value">{{ store.stats().visitedCount }}</span> | |
| 10 | + <span class="stat__label">{{ store.stats().visitedCount > 1 ? 'lieux visités' : 'lieu visité' }}</span> | |
| 11 | + </li> | |
| 12 | + <li class="card stat"> | |
| 13 | + <span class="stat__value">{{ store.stats().countryCount }}</span> | |
| 14 | + <span class="stat__label">pays</span> | |
| 15 | + </li> | |
| 16 | + <li class="card stat"> | |
| 17 | + <span class="stat__value">{{ store.stats().averageRating ?? '—' }}</span> | |
| 18 | + <span class="stat__label">note moyenne</span> | |
| 19 | + </li> | |
| 20 | + <li class="card stat"> | |
| 21 | + <span class="stat__value">{{ store.stats().wishlistCount }}</span> | |
| 22 | + <span class="stat__label">à découvrir</span> | |
| 23 | + </li> | |
| 24 | + </ul> | |
| 25 | + | |
| 26 | + <div class="layout layout--map-first"> | |
| 27 | + <section class="card" aria-labelledby="map-title"> | |
| 28 | + <div class="card__head"> | |
| 29 | + <h2 id="map-title">Carte</h2> | |
| 30 | + <ul class="legend" aria-label="Légende"> | |
| 31 | + <li><span class="legend__dot legend__dot--visited" aria-hidden="true"></span>Visité</li> | |
| 32 | + <li><span class="legend__dot legend__dot--wishlist" aria-hidden="true"></span>À visiter</li> | |
| 33 | + </ul> | |
| 34 | + </div> | |
| 35 | + <div class="map-slot"> | |
| 36 | + <app-travel-map [places]="store.places()" label="Carte de vos lieux visités et de vos souhaits"></app-travel-map> | |
| 37 | + </div> | |
| 38 | + </section> | |
| 39 | + | |
| 40 | + <section class="card" aria-labelledby="recent-title"> | |
| 41 | + <h2 id="recent-title">Derniers voyages</h2> | |
| 42 | + | |
| 43 | + <ol class="timeline" *ngIf="recent().length > 0; else noTrip"> | |
| 44 | + <li class="timeline__item" *ngFor="let place of recent()"> | |
| 45 | + <div class="timeline__main"> | |
| 46 | + <strong>{{ place.name }}</strong> | |
| 47 | + <span class="muted">{{ place.country }} · {{ categoryLabels[place.category] }}</span> | |
| 48 | + </div> | |
| 49 | + <div class="timeline__meta"> | |
| 50 | + <time [attr.datetime]="place.visitedOn">{{ place.visitedOn | date: 'longDate' }}</time> | |
| 51 | + <app-rating [value]="place.rating"></app-rating> | |
| 52 | + </div> | |
| 53 | + <p class="timeline__note" *ngIf="place.note">{{ place.note }}</p> | |
| 54 | + </li> | |
| 55 | + </ol> | |
| 56 | + | |
| 57 | + <ng-template #noTrip> | |
| 58 | + <p class="empty"> | |
| 59 | + Aucun lieu visité pour l’instant. | |
| 60 | + <a routerLink="/lieux/nouveau">Ajoutez votre premier voyage</a>. | |
| 61 | + </p> | |
| 62 | + </ng-template> | |
| 63 | + </section> | |
| 64 | + </div> | |
| 65 | +</section> | |
| new file mode 100644 | |||
| @@ -0,0 +1,65 @@ | |||
| 1 | +<section class="page"> | ||
| 2 | + <header class="page__header"> | ||
| 3 | + <p class="eyebrow">Carnet de voyage</p> | ||
| 4 | + <h1>Votre monde en un coup d’œil</h1> | ||
| 5 | + </header> | ||
| 6 | + | ||
| 7 | + <ul class="stat-grid" aria-label="Statistiques de voyage"> | ||
| 8 | + <li class="card stat"> | ||
| 9 | + <span class="stat__value">{{ store.stats().visitedCount }}</span> | ||
| 10 | + <span class="stat__label">{{ store.stats().visitedCount > 1 ? 'lieux visités' : 'lieu visité' }}</span> | ||
| 11 | + </li> | ||
| 12 | + <li class="card stat"> | ||
| 13 | + <span class="stat__value">{{ store.stats().countryCount }}</span> | ||
| 14 | + <span class="stat__label">pays</span> | ||
| 15 | + </li> | ||
| 16 | + <li class="card stat"> | ||
| 17 | + <span class="stat__value">{{ store.stats().averageRating ?? '—' }}</span> | ||
| 18 | + <span class="stat__label">note moyenne</span> | ||
| 19 | + </li> | ||
| 20 | + <li class="card stat"> | ||
| 21 | + <span class="stat__value">{{ store.stats().wishlistCount }}</span> | ||
| 22 | + <span class="stat__label">à découvrir</span> | ||
| 23 | + </li> | ||
| 24 | + </ul> | ||
| 25 | + | ||
| 26 | + <div class="layout layout--map-first"> | ||
| 27 | + <section class="card" aria-labelledby="map-title"> | ||
| 28 | + <div class="card__head"> | ||
| 29 | + <h2 id="map-title">Carte</h2> | ||
| 30 | + <ul class="legend" aria-label="Légende"> | ||
| 31 | + <li><span class="legend__dot legend__dot--visited" aria-hidden="true"></span>Visité</li> | ||
| 32 | + <li><span class="legend__dot legend__dot--wishlist" aria-hidden="true"></span>À visiter</li> | ||
| 33 | + </ul> | ||
| 34 | + </div> | ||
| 35 | + <div class="map-slot"> | ||
| 36 | + <app-travel-map [places]="store.places()" label="Carte de vos lieux visités et de vos souhaits"></app-travel-map> | ||
| 37 | + </div> | ||
| 38 | + </section> | ||
| 39 | + | ||
| 40 | + <section class="card" aria-labelledby="recent-title"> | ||
| 41 | + <h2 id="recent-title">Derniers voyages</h2> | ||
| 42 | + | ||
| 43 | + <ol class="timeline" *ngIf="recent().length > 0; else noTrip"> | ||
| 44 | + <li class="timeline__item" *ngFor="let place of recent()"> | ||
| 45 | + <div class="timeline__main"> | ||
| 46 | + <strong>{{ place.name }}</strong> | ||
| 47 | + <span class="muted">{{ place.country }} · {{ categoryLabels[place.category] }}</span> | ||
| 48 | + </div> | ||
| 49 | + <div class="timeline__meta"> | ||
| 50 | + <time [attr.datetime]="place.visitedOn">{{ place.visitedOn | date: 'longDate' }}</time> | ||
| 51 | + <app-rating [value]="place.rating"></app-rating> | ||
| 52 | + </div> | ||
| 53 | + <p class="timeline__note" *ngIf="place.note">{{ place.note }}</p> | ||
| 54 | + </li> | ||
| 55 | + </ol> | ||
| 56 | + | ||
| 57 | + <ng-template #noTrip> | ||
| 58 | + <p class="empty"> | ||
| 59 | + Aucun lieu visité pour l’instant. | ||
| 60 | + <a routerLink="/lieux/nouveau">Ajoutez votre premier voyage</a>. | ||
| 61 | + </p> | ||
| 62 | + </ng-template> | ||
| 63 | + </section> | ||
| 64 | + </div> | ||
| 65 | +</section> | ||
added
src/app/travel/pages/summary-page/summary-page.component.spec.ts +38 -0 | new file mode 100644 | ||
| @@ -0,0 +1,38 @@ | ||
| 1 | +import { TestBed } from '@angular/core/testing'; | |
| 2 | +import { provideRouter } from '@angular/router'; | |
| 3 | +import { memoryStorage } from '../../testing/memory-storage'; | |
| 4 | +import { STORAGE_KEY, TRAVEL_STORAGE } from '../../services/travel-store.service'; | |
| 5 | +import { SummaryPageComponent } from './summary-page.component'; | |
| 6 | + | |
| 7 | +describe('SummaryPageComponent', () => { | |
| 8 | + async function render(stored?: string) { | |
| 9 | + await TestBed.configureTestingModule({ | |
| 10 | + imports: [SummaryPageComponent], | |
| 11 | + providers: [ | |
| 12 | + provideRouter([]), | |
| 13 | + { provide: TRAVEL_STORAGE, useValue: memoryStorage(stored ? { [STORAGE_KEY]: stored } : {}) }, | |
| 14 | + ], | |
| 15 | + }).compileComponents(); | |
| 16 | + | |
| 17 | + const fixture = TestBed.createComponent(SummaryPageComponent); | |
| 18 | + fixture.detectChanges(); | |
| 19 | + return fixture.nativeElement as HTMLElement; | |
| 20 | + } | |
| 21 | + | |
| 22 | + it('affiche les statistiques, la carte et les derniers voyages', async () => { | |
| 23 | + const root = await render(); | |
| 24 | + const values = Array.from(root.querySelectorAll('.stat__value')).map((el) => el.textContent?.trim()); | |
| 25 | + | |
| 26 | + expect(values).toEqual(['5', '5', '4.6', '5']); | |
| 27 | + expect(root.querySelector('app-travel-map')).not.toBeNull(); | |
| 28 | + expect(root.querySelectorAll('.timeline__item').length).toBe(5); | |
| 29 | + expect(root.querySelector('.timeline__item strong')?.textContent).toBe('Col du Galibier'); | |
| 30 | + }); | |
| 31 | + | |
| 32 | + it('invite à ajouter un premier voyage quand rien n’est enregistré', async () => { | |
| 33 | + const root = await render('[]'); | |
| 34 | + | |
| 35 | + expect(root.querySelector('.empty a')?.getAttribute('href')).toBe('/lieux/nouveau'); | |
| 36 | + expect(root.querySelector('.timeline')).toBeNull(); | |
| 37 | + }); | |
| 38 | +}); | |
| new file mode 100644 | |||
| @@ -0,0 +1,38 @@ | |||
| 1 | +import { TestBed } from '@angular/core/testing'; | ||
| 2 | +import { provideRouter } from '@angular/router'; | ||
| 3 | +import { memoryStorage } from '../../testing/memory-storage'; | ||
| 4 | +import { STORAGE_KEY, TRAVEL_STORAGE } from '../../services/travel-store.service'; | ||
| 5 | +import { SummaryPageComponent } from './summary-page.component'; | ||
| 6 | + | ||
| 7 | +describe('SummaryPageComponent', () => { | ||
| 8 | + async function render(stored?: string) { | ||
| 9 | + await TestBed.configureTestingModule({ | ||
| 10 | + imports: [SummaryPageComponent], | ||
| 11 | + providers: [ | ||
| 12 | + provideRouter([]), | ||
| 13 | + { provide: TRAVEL_STORAGE, useValue: memoryStorage(stored ? { [STORAGE_KEY]: stored } : {}) }, | ||
| 14 | + ], | ||
| 15 | + }).compileComponents(); | ||
| 16 | + | ||
| 17 | + const fixture = TestBed.createComponent(SummaryPageComponent); | ||
| 18 | + fixture.detectChanges(); | ||
| 19 | + return fixture.nativeElement as HTMLElement; | ||
| 20 | + } | ||
| 21 | + | ||
| 22 | + it('affiche les statistiques, la carte et les derniers voyages', async () => { | ||
| 23 | + const root = await render(); | ||
| 24 | + const values = Array.from(root.querySelectorAll('.stat__value')).map((el) => el.textContent?.trim()); | ||
| 25 | + | ||
| 26 | + expect(values).toEqual(['5', '5', '4.6', '5']); | ||
| 27 | + expect(root.querySelector('app-travel-map')).not.toBeNull(); | ||
| 28 | + expect(root.querySelectorAll('.timeline__item').length).toBe(5); | ||
| 29 | + expect(root.querySelector('.timeline__item strong')?.textContent).toBe('Col du Galibier'); | ||
| 30 | + }); | ||
| 31 | + | ||
| 32 | + it('invite à ajouter un premier voyage quand rien n’est enregistré', async () => { | ||
| 33 | + const root = await render('[]'); | ||
| 34 | + | ||
| 35 | + expect(root.querySelector('.empty a')?.getAttribute('href')).toBe('/lieux/nouveau'); | ||
| 36 | + expect(root.querySelector('.timeline')).toBeNull(); | ||
| 37 | + }); | ||
| 38 | +}); | ||
added
src/app/travel/pages/summary-page/summary-page.component.ts +22 -0 | new file mode 100644 | ||
| @@ -0,0 +1,22 @@ | ||
| 1 | +import { CommonModule } from '@angular/common'; | |
| 2 | +import { Component, Signal, computed } from '@angular/core'; | |
| 3 | +import { RouterLink } from '@angular/router'; | |
| 4 | +import { RatingComponent } from '../../components/rating/rating.component'; | |
| 5 | +import { TravelMapComponent } from '../../components/travel-map/travel-map.component'; | |
| 6 | +import { CATEGORY_LABELS, Place } from '../../models/place.model'; | |
| 7 | +import { TravelStore } from '../../services/travel-store.service'; | |
| 8 | + | |
| 9 | +@Component({ | |
| 10 | + selector: 'app-summary-page', | |
| 11 | + standalone: true, | |
| 12 | + imports: [CommonModule, RouterLink, RatingComponent, TravelMapComponent], | |
| 13 | + templateUrl: './summary-page.component.html', | |
| 14 | +}) | |
| 15 | +export class SummaryPageComponent { | |
| 16 | + categoryLabels = CATEGORY_LABELS; | |
| 17 | + recent: Signal<Place[]>; | |
| 18 | + | |
| 19 | + constructor(public store: TravelStore) { | |
| 20 | + this.recent = computed(() => this.store.visited().slice(0, 5)); | |
| 21 | + } | |
| 22 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,22 @@ | |||
| 1 | +import { CommonModule } from '@angular/common'; | ||
| 2 | +import { Component, Signal, computed } from '@angular/core'; | ||
| 3 | +import { RouterLink } from '@angular/router'; | ||
| 4 | +import { RatingComponent } from '../../components/rating/rating.component'; | ||
| 5 | +import { TravelMapComponent } from '../../components/travel-map/travel-map.component'; | ||
| 6 | +import { CATEGORY_LABELS, Place } from '../../models/place.model'; | ||
| 7 | +import { TravelStore } from '../../services/travel-store.service'; | ||
| 8 | + | ||
| 9 | +@Component({ | ||
| 10 | + selector: 'app-summary-page', | ||
| 11 | + standalone: true, | ||
| 12 | + imports: [CommonModule, RouterLink, RatingComponent, TravelMapComponent], | ||
| 13 | + templateUrl: './summary-page.component.html', | ||
| 14 | +}) | ||
| 15 | +export class SummaryPageComponent { | ||
| 16 | + categoryLabels = CATEGORY_LABELS; | ||
| 17 | + recent: Signal<Place[]>; | ||
| 18 | + | ||
| 19 | + constructor(public store: TravelStore) { | ||
| 20 | + this.recent = computed(() => this.store.visited().slice(0, 5)); | ||
| 21 | + } | ||
| 22 | +} | ||
added
src/app/travel/pages/wishlist-page/wishlist-page.component.html +82 -0 | new file mode 100644 | ||
| @@ -0,0 +1,82 @@ | ||
| 1 | +<section class="page"> | |
| 2 | + <header class="page__header"> | |
| 3 | + <p class="eyebrow">Liste de souhaits</p> | |
| 4 | + <h1>Là où vous voulez aller</h1> | |
| 5 | + </header> | |
| 6 | + | |
| 7 | + <p class="notice" role="status" aria-live="polite"> | |
| 8 | + <ng-container *ngIf="justVisited"> | |
| 9 | + {{ justVisited }} rejoint vos lieux visités. <a routerLink="/resume">Voir le résumé</a> | |
| 10 | + </ng-container> | |
| 11 | + </p> | |
| 12 | + | |
| 13 | + <div class="layout"> | |
| 14 | + <section class="card" aria-labelledby="wishlist-title"> | |
| 15 | + <h2 id="wishlist-title"> | |
| 16 | + {{ store.wishlist().length }} {{ store.wishlist().length > 1 ? 'destinations' : 'destination' }} | |
| 17 | + </h2> | |
| 18 | + | |
| 19 | + <ul class="wish-list" *ngIf="store.wishlist().length > 0; else emptyList"> | |
| 20 | + <li class="wish" *ngFor="let place of store.wishlist()"> | |
| 21 | + <div class="wish__head"> | |
| 22 | + <strong>{{ place.name }}</strong> | |
| 23 | + <span class="badge" [ngClass]="'badge--' + (place.priority || 'medium')"> | |
| 24 | + {{ priorityLabels[place.priority || 'medium'] }} | |
| 25 | + </span> | |
| 26 | + </div> | |
| 27 | + <span class="muted">{{ place.country }} · {{ categoryLabels[place.category] }}</span> | |
| 28 | + <p class="wish__note" *ngIf="place.note">{{ place.note }}</p> | |
| 29 | + | |
| 30 | + <div class="actions"> | |
| 31 | + <button | |
| 32 | + type="button" | |
| 33 | + class="btn btn--primary btn--sm" | |
| 34 | + [attr.aria-label]="'Marquer ' + place.name + ' comme visité'" | |
| 35 | + (click)="markVisited(place)" | |
| 36 | + > | |
| 37 | + Marquer comme visité | |
| 38 | + </button> | |
| 39 | + | |
| 40 | + <ng-container *ngIf="pendingRemoval === place.id; else removeButton"> | |
| 41 | + <button | |
| 42 | + type="button" | |
| 43 | + class="btn btn--danger btn--sm" | |
| 44 | + [attr.aria-label]="'Confirmer la suppression de ' + place.name" | |
| 45 | + (click)="confirmRemoval(place)" | |
| 46 | + > | |
| 47 | + Confirmer | |
| 48 | + </button> | |
| 49 | + <button type="button" class="btn btn--sm" (click)="cancelRemoval()">Annuler</button> | |
| 50 | + </ng-container> | |
| 51 | + <ng-template #removeButton> | |
| 52 | + <button | |
| 53 | + type="button" | |
| 54 | + class="btn btn--sm" | |
| 55 | + [attr.aria-label]="'Supprimer ' + place.name" | |
| 56 | + (click)="askRemoval(place)" | |
| 57 | + > | |
| 58 | + Supprimer | |
| 59 | + </button> | |
| 60 | + </ng-template> | |
| 61 | + </div> | |
| 62 | + </li> | |
| 63 | + </ul> | |
| 64 | + | |
| 65 | + <ng-template #emptyList> | |
| 66 | + <p class="empty"> | |
| 67 | + Votre liste est vide. <a routerLink="/lieux/nouveau">Ajoutez une destination</a> qui vous fait rêver. | |
| 68 | + </p> | |
| 69 | + </ng-template> | |
| 70 | + </section> | |
| 71 | + | |
| 72 | + <section class="card" aria-labelledby="wish-map-title"> | |
| 73 | + <h2 id="wish-map-title">Sur la carte</h2> | |
| 74 | + <div class="map-slot"> | |
| 75 | + <app-travel-map | |
| 76 | + [places]="store.wishlist()" | |
| 77 | + label="Carte des destinations de votre liste de souhaits" | |
| 78 | + ></app-travel-map> | |
| 79 | + </div> | |
| 80 | + </section> | |
| 81 | + </div> | |
| 82 | +</section> | |
| new file mode 100644 | |||
| @@ -0,0 +1,82 @@ | |||
| 1 | +<section class="page"> | ||
| 2 | + <header class="page__header"> | ||
| 3 | + <p class="eyebrow">Liste de souhaits</p> | ||
| 4 | + <h1>Là où vous voulez aller</h1> | ||
| 5 | + </header> | ||
| 6 | + | ||
| 7 | + <p class="notice" role="status" aria-live="polite"> | ||
| 8 | + <ng-container *ngIf="justVisited"> | ||
| 9 | + {{ justVisited }} rejoint vos lieux visités. <a routerLink="/resume">Voir le résumé</a> | ||
| 10 | + </ng-container> | ||
| 11 | + </p> | ||
| 12 | + | ||
| 13 | + <div class="layout"> | ||
| 14 | + <section class="card" aria-labelledby="wishlist-title"> | ||
| 15 | + <h2 id="wishlist-title"> | ||
| 16 | + {{ store.wishlist().length }} {{ store.wishlist().length > 1 ? 'destinations' : 'destination' }} | ||
| 17 | + </h2> | ||
| 18 | + | ||
| 19 | + <ul class="wish-list" *ngIf="store.wishlist().length > 0; else emptyList"> | ||
| 20 | + <li class="wish" *ngFor="let place of store.wishlist()"> | ||
| 21 | + <div class="wish__head"> | ||
| 22 | + <strong>{{ place.name }}</strong> | ||
| 23 | + <span class="badge" [ngClass]="'badge--' + (place.priority || 'medium')"> | ||
| 24 | + {{ priorityLabels[place.priority || 'medium'] }} | ||
| 25 | + </span> | ||
| 26 | + </div> | ||
| 27 | + <span class="muted">{{ place.country }} · {{ categoryLabels[place.category] }}</span> | ||
| 28 | + <p class="wish__note" *ngIf="place.note">{{ place.note }}</p> | ||
| 29 | + | ||
| 30 | + <div class="actions"> | ||
| 31 | + <button | ||
| 32 | + type="button" | ||
| 33 | + class="btn btn--primary btn--sm" | ||
| 34 | + [attr.aria-label]="'Marquer ' + place.name + ' comme visité'" | ||
| 35 | + (click)="markVisited(place)" | ||
| 36 | + > | ||
| 37 | + Marquer comme visité | ||
| 38 | + </button> | ||
| 39 | + | ||
| 40 | + <ng-container *ngIf="pendingRemoval === place.id; else removeButton"> | ||
| 41 | + <button | ||
| 42 | + type="button" | ||
| 43 | + class="btn btn--danger btn--sm" | ||
| 44 | + [attr.aria-label]="'Confirmer la suppression de ' + place.name" | ||
| 45 | + (click)="confirmRemoval(place)" | ||
| 46 | + > | ||
| 47 | + Confirmer | ||
| 48 | + </button> | ||
| 49 | + <button type="button" class="btn btn--sm" (click)="cancelRemoval()">Annuler</button> | ||
| 50 | + </ng-container> | ||
| 51 | + <ng-template #removeButton> | ||
| 52 | + <button | ||
| 53 | + type="button" | ||
| 54 | + class="btn btn--sm" | ||
| 55 | + [attr.aria-label]="'Supprimer ' + place.name" | ||
| 56 | + (click)="askRemoval(place)" | ||
| 57 | + > | ||
| 58 | + Supprimer | ||
| 59 | + </button> | ||
| 60 | + </ng-template> | ||
| 61 | + </div> | ||
| 62 | + </li> | ||
| 63 | + </ul> | ||
| 64 | + | ||
| 65 | + <ng-template #emptyList> | ||
| 66 | + <p class="empty"> | ||
| 67 | + Votre liste est vide. <a routerLink="/lieux/nouveau">Ajoutez une destination</a> qui vous fait rêver. | ||
| 68 | + </p> | ||
| 69 | + </ng-template> | ||
| 70 | + </section> | ||
| 71 | + | ||
| 72 | + <section class="card" aria-labelledby="wish-map-title"> | ||
| 73 | + <h2 id="wish-map-title">Sur la carte</h2> | ||
| 74 | + <div class="map-slot"> | ||
| 75 | + <app-travel-map | ||
| 76 | + [places]="store.wishlist()" | ||
| 77 | + label="Carte des destinations de votre liste de souhaits" | ||
| 78 | + ></app-travel-map> | ||
| 79 | + </div> | ||
| 80 | + </section> | ||
| 81 | + </div> | ||
| 82 | +</section> | ||
added
src/app/travel/pages/wishlist-page/wishlist-page.component.spec.ts +83 -0 | new file mode 100644 | ||
| @@ -0,0 +1,83 @@ | ||
| 1 | +import { ComponentFixture, TestBed } from '@angular/core/testing'; | |
| 2 | +import { provideRouter } from '@angular/router'; | |
| 3 | +import { SEED_PLACES } from '../../data/seed-places'; | |
| 4 | +import { memoryStorage } from '../../testing/memory-storage'; | |
| 5 | +import { STORAGE_KEY, TRAVEL_STORAGE, TravelStore } from '../../services/travel-store.service'; | |
| 6 | +import { WishlistPageComponent } from './wishlist-page.component'; | |
| 7 | + | |
| 8 | +describe('WishlistPageComponent', () => { | |
| 9 | + let fixture: ComponentFixture<WishlistPageComponent>; | |
| 10 | + let root: HTMLElement; | |
| 11 | + let store: TravelStore; | |
| 12 | + | |
| 13 | + const wishCount = SEED_PLACES.filter((place) => place.status === 'wishlist').length; | |
| 14 | + | |
| 15 | + async function setup(stored = '') { | |
| 16 | + await TestBed.configureTestingModule({ | |
| 17 | + imports: [WishlistPageComponent], | |
| 18 | + providers: [ | |
| 19 | + provideRouter([]), | |
| 20 | + { provide: TRAVEL_STORAGE, useValue: memoryStorage(stored ? { [STORAGE_KEY]: stored } : {}) }, | |
| 21 | + ], | |
| 22 | + }).compileComponents(); | |
| 23 | + | |
| 24 | + store = TestBed.inject(TravelStore); | |
| 25 | + fixture = TestBed.createComponent(WishlistPageComponent); | |
| 26 | + root = fixture.nativeElement as HTMLElement; | |
| 27 | + fixture.detectChanges(); | |
| 28 | + } | |
| 29 | + | |
| 30 | + function buttonLabelled(label: string): HTMLButtonElement { | |
| 31 | + return root.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`) as HTMLButtonElement; | |
| 32 | + } | |
| 33 | + | |
| 34 | + it('liste les destinations par priorité', async () => { | |
| 35 | + await setup(); | |
| 36 | + const names = Array.from(root.querySelectorAll('.wish .wish__head strong')).map((el) => el.textContent?.trim()); | |
| 37 | + | |
| 38 | + expect(names.length).toBe(wishCount); | |
| 39 | + expect(names.at(-1)).toBe('Îles Lofoten'); // seule priorité basse | |
| 40 | + }); | |
| 41 | + | |
| 42 | + it('affiche un message quand la liste est vide', async () => { | |
| 43 | + await setup('[]'); | |
| 44 | + expect(root.querySelector('.empty')?.textContent).toContain('Votre liste est vide'); | |
| 45 | + }); | |
| 46 | + | |
| 47 | + it('marque un souhait comme visité et prévient l’utilisateur', async () => { | |
| 48 | + await setup(); | |
| 49 | + | |
| 50 | + buttonLabelled('Marquer Kyoto comme visité').click(); | |
| 51 | + fixture.detectChanges(); | |
| 52 | + | |
| 53 | + expect(store.wishlist().some((place) => place.name === 'Kyoto')).toBeFalse(); | |
| 54 | + expect(store.visited().some((place) => place.name === 'Kyoto')).toBeTrue(); | |
| 55 | + expect(root.querySelector('.notice')?.textContent).toContain('Kyoto rejoint vos lieux visités'); | |
| 56 | + }); | |
| 57 | + | |
| 58 | + it('demande une confirmation avant de supprimer', async () => { | |
| 59 | + await setup(); | |
| 60 | + | |
| 61 | + buttonLabelled('Supprimer Kyoto').click(); | |
| 62 | + fixture.detectChanges(); | |
| 63 | + expect(store.wishlist().some((place) => place.name === 'Kyoto')).toBeTrue(); | |
| 64 | + | |
| 65 | + buttonLabelled('Confirmer la suppression de Kyoto').click(); | |
| 66 | + fixture.detectChanges(); | |
| 67 | + expect(store.wishlist().some((place) => place.name === 'Kyoto')).toBeFalse(); | |
| 68 | + }); | |
| 69 | + | |
| 70 | + it('permet d’annuler la suppression', async () => { | |
| 71 | + await setup(); | |
| 72 | + | |
| 73 | + buttonLabelled('Supprimer Kyoto').click(); | |
| 74 | + fixture.detectChanges(); | |
| 75 | + Array.from(root.querySelectorAll<HTMLButtonElement>('button')) | |
| 76 | + .find((button) => button.textContent?.trim() === 'Annuler') | |
| 77 | + ?.click(); | |
| 78 | + fixture.detectChanges(); | |
| 79 | + | |
| 80 | + expect(buttonLabelled('Supprimer Kyoto')).not.toBeNull(); | |
| 81 | + expect(store.wishlist().length).toBe(wishCount); | |
| 82 | + }); | |
| 83 | +}); | |
| new file mode 100644 | |||
| @@ -0,0 +1,83 @@ | |||
| 1 | +import { ComponentFixture, TestBed } from '@angular/core/testing'; | ||
| 2 | +import { provideRouter } from '@angular/router'; | ||
| 3 | +import { SEED_PLACES } from '../../data/seed-places'; | ||
| 4 | +import { memoryStorage } from '../../testing/memory-storage'; | ||
| 5 | +import { STORAGE_KEY, TRAVEL_STORAGE, TravelStore } from '../../services/travel-store.service'; | ||
| 6 | +import { WishlistPageComponent } from './wishlist-page.component'; | ||
| 7 | + | ||
| 8 | +describe('WishlistPageComponent', () => { | ||
| 9 | + let fixture: ComponentFixture<WishlistPageComponent>; | ||
| 10 | + let root: HTMLElement; | ||
| 11 | + let store: TravelStore; | ||
| 12 | + | ||
| 13 | + const wishCount = SEED_PLACES.filter((place) => place.status === 'wishlist').length; | ||
| 14 | + | ||
| 15 | + async function setup(stored = '') { | ||
| 16 | + await TestBed.configureTestingModule({ | ||
| 17 | + imports: [WishlistPageComponent], | ||
| 18 | + providers: [ | ||
| 19 | + provideRouter([]), | ||
| 20 | + { provide: TRAVEL_STORAGE, useValue: memoryStorage(stored ? { [STORAGE_KEY]: stored } : {}) }, | ||
| 21 | + ], | ||
| 22 | + }).compileComponents(); | ||
| 23 | + | ||
| 24 | + store = TestBed.inject(TravelStore); | ||
| 25 | + fixture = TestBed.createComponent(WishlistPageComponent); | ||
| 26 | + root = fixture.nativeElement as HTMLElement; | ||
| 27 | + fixture.detectChanges(); | ||
| 28 | + } | ||
| 29 | + | ||
| 30 | + function buttonLabelled(label: string): HTMLButtonElement { | ||
| 31 | + return root.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`) as HTMLButtonElement; | ||
| 32 | + } | ||
| 33 | + | ||
| 34 | + it('liste les destinations par priorité', async () => { | ||
| 35 | + await setup(); | ||
| 36 | + const names = Array.from(root.querySelectorAll('.wish .wish__head strong')).map((el) => el.textContent?.trim()); | ||
| 37 | + | ||
| 38 | + expect(names.length).toBe(wishCount); | ||
| 39 | + expect(names.at(-1)).toBe('Îles Lofoten'); // seule priorité basse | ||
| 40 | + }); | ||
| 41 | + | ||
| 42 | + it('affiche un message quand la liste est vide', async () => { | ||
| 43 | + await setup('[]'); | ||
| 44 | + expect(root.querySelector('.empty')?.textContent).toContain('Votre liste est vide'); | ||
| 45 | + }); | ||
| 46 | + | ||
| 47 | + it('marque un souhait comme visité et prévient l’utilisateur', async () => { | ||
| 48 | + await setup(); | ||
| 49 | + | ||
| 50 | + buttonLabelled('Marquer Kyoto comme visité').click(); | ||
| 51 | + fixture.detectChanges(); | ||
| 52 | + | ||
| 53 | + expect(store.wishlist().some((place) => place.name === 'Kyoto')).toBeFalse(); | ||
| 54 | + expect(store.visited().some((place) => place.name === 'Kyoto')).toBeTrue(); | ||
| 55 | + expect(root.querySelector('.notice')?.textContent).toContain('Kyoto rejoint vos lieux visités'); | ||
| 56 | + }); | ||
| 57 | + | ||
| 58 | + it('demande une confirmation avant de supprimer', async () => { | ||
| 59 | + await setup(); | ||
| 60 | + | ||
| 61 | + buttonLabelled('Supprimer Kyoto').click(); | ||
| 62 | + fixture.detectChanges(); | ||
| 63 | + expect(store.wishlist().some((place) => place.name === 'Kyoto')).toBeTrue(); | ||
| 64 | + | ||
| 65 | + buttonLabelled('Confirmer la suppression de Kyoto').click(); | ||
| 66 | + fixture.detectChanges(); | ||
| 67 | + expect(store.wishlist().some((place) => place.name === 'Kyoto')).toBeFalse(); | ||
| 68 | + }); | ||
| 69 | + | ||
| 70 | + it('permet d’annuler la suppression', async () => { | ||
| 71 | + await setup(); | ||
| 72 | + | ||
| 73 | + buttonLabelled('Supprimer Kyoto').click(); | ||
| 74 | + fixture.detectChanges(); | ||
| 75 | + Array.from(root.querySelectorAll<HTMLButtonElement>('button')) | ||
| 76 | + .find((button) => button.textContent?.trim() === 'Annuler') | ||
| 77 | + ?.click(); | ||
| 78 | + fixture.detectChanges(); | ||
| 79 | + | ||
| 80 | + expect(buttonLabelled('Supprimer Kyoto')).not.toBeNull(); | ||
| 81 | + expect(store.wishlist().length).toBe(wishCount); | ||
| 82 | + }); | ||
| 83 | +}); | ||
added
src/app/travel/pages/wishlist-page/wishlist-page.component.ts +44 -0 | new file mode 100644 | ||
| @@ -0,0 +1,44 @@ | ||
| 1 | +import { CommonModule } from '@angular/common'; | |
| 2 | +import { Component } from '@angular/core'; | |
| 3 | +import { RouterLink } from '@angular/router'; | |
| 4 | +import { TravelMapComponent } from '../../components/travel-map/travel-map.component'; | |
| 5 | +import { CATEGORY_LABELS, PRIORITY_LABELS, Place } from '../../models/place.model'; | |
| 6 | +import { TravelStore } from '../../services/travel-store.service'; | |
| 7 | +import { todayIso } from '../../utils/date'; | |
| 8 | + | |
| 9 | +@Component({ | |
| 10 | + selector: 'app-wishlist-page', | |
| 11 | + standalone: true, | |
| 12 | + imports: [CommonModule, RouterLink, TravelMapComponent], | |
| 13 | + templateUrl: './wishlist-page.component.html', | |
| 14 | +}) | |
| 15 | +export class WishlistPageComponent { | |
| 16 | + categoryLabels = CATEGORY_LABELS; | |
| 17 | + priorityLabels = PRIORITY_LABELS; | |
| 18 | + | |
| 19 | + /** Lieu dont la suppression attend une confirmation. */ | |
| 20 | + pendingRemoval: string | null = null; | |
| 21 | + /** Nom du dernier lieu marqué comme visité, annoncé aux lecteurs d'écran. */ | |
| 22 | + justVisited: string | null = null; | |
| 23 | + | |
| 24 | + constructor(public store: TravelStore) {} | |
| 25 | + | |
| 26 | + markVisited(place: Place): void { | |
| 27 | + this.store.markVisited(place.id, todayIso()); | |
| 28 | + this.justVisited = place.name; | |
| 29 | + this.pendingRemoval = null; | |
| 30 | + } | |
| 31 | + | |
| 32 | + askRemoval(place: Place): void { | |
| 33 | + this.pendingRemoval = place.id; | |
| 34 | + } | |
| 35 | + | |
| 36 | + cancelRemoval(): void { | |
| 37 | + this.pendingRemoval = null; | |
| 38 | + } | |
| 39 | + | |
| 40 | + confirmRemoval(place: Place): void { | |
| 41 | + this.store.remove(place.id); | |
| 42 | + this.pendingRemoval = null; | |
| 43 | + } | |
| 44 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,44 @@ | |||
| 1 | +import { CommonModule } from '@angular/common'; | ||
| 2 | +import { Component } from '@angular/core'; | ||
| 3 | +import { RouterLink } from '@angular/router'; | ||
| 4 | +import { TravelMapComponent } from '../../components/travel-map/travel-map.component'; | ||
| 5 | +import { CATEGORY_LABELS, PRIORITY_LABELS, Place } from '../../models/place.model'; | ||
| 6 | +import { TravelStore } from '../../services/travel-store.service'; | ||
| 7 | +import { todayIso } from '../../utils/date'; | ||
| 8 | + | ||
| 9 | +@Component({ | ||
| 10 | + selector: 'app-wishlist-page', | ||
| 11 | + standalone: true, | ||
| 12 | + imports: [CommonModule, RouterLink, TravelMapComponent], | ||
| 13 | + templateUrl: './wishlist-page.component.html', | ||
| 14 | +}) | ||
| 15 | +export class WishlistPageComponent { | ||
| 16 | + categoryLabels = CATEGORY_LABELS; | ||
| 17 | + priorityLabels = PRIORITY_LABELS; | ||
| 18 | + | ||
| 19 | + /** Lieu dont la suppression attend une confirmation. */ | ||
| 20 | + pendingRemoval: string | null = null; | ||
| 21 | + /** Nom du dernier lieu marqué comme visité, annoncé aux lecteurs d'écran. */ | ||
| 22 | + justVisited: string | null = null; | ||
| 23 | + | ||
| 24 | + constructor(public store: TravelStore) {} | ||
| 25 | + | ||
| 26 | + markVisited(place: Place): void { | ||
| 27 | + this.store.markVisited(place.id, todayIso()); | ||
| 28 | + this.justVisited = place.name; | ||
| 29 | + this.pendingRemoval = null; | ||
| 30 | + } | ||
| 31 | + | ||
| 32 | + askRemoval(place: Place): void { | ||
| 33 | + this.pendingRemoval = place.id; | ||
| 34 | + } | ||
| 35 | + | ||
| 36 | + cancelRemoval(): void { | ||
| 37 | + this.pendingRemoval = null; | ||
| 38 | + } | ||
| 39 | + | ||
| 40 | + confirmRemoval(place: Place): void { | ||
| 41 | + this.store.remove(place.id); | ||
| 42 | + this.pendingRemoval = null; | ||
| 43 | + } | ||
| 44 | +} | ||
added
src/app/travel/services/travel-store.service.spec.ts +112 -0 | new file mode 100644 | ||
| @@ -0,0 +1,112 @@ | ||
| 1 | +import { TestBed } from '@angular/core/testing'; | |
| 2 | +import { SEED_PLACES } from '../data/seed-places'; | |
| 3 | +import { NewPlace } from '../models/place.model'; | |
| 4 | +import { memoryStorage } from '../testing/memory-storage'; | |
| 5 | +import { STORAGE_KEY, TRAVEL_STORAGE, TravelStore } from './travel-store.service'; | |
| 6 | + | |
| 7 | +const annecy: NewPlace = { | |
| 8 | + name: 'Annecy', | |
| 9 | + country: 'France', | |
| 10 | + category: 'city', | |
| 11 | + status: 'visited', | |
| 12 | + lat: 45.8992, | |
| 13 | + lng: 6.1294, | |
| 14 | + visitedOn: '2024-08-01', | |
| 15 | + rating: 4, | |
| 16 | +}; | |
| 17 | + | |
| 18 | +describe('TravelStore', () => { | |
| 19 | + function setup(storage: ReturnType<typeof memoryStorage> | null = memoryStorage()) { | |
| 20 | + TestBed.configureTestingModule({ providers: [{ provide: TRAVEL_STORAGE, useValue: storage }] }); | |
| 21 | + return TestBed.inject(TravelStore); | |
| 22 | + } | |
| 23 | + | |
| 24 | + it('démarre avec les lieux d’exemple quand rien n’est enregistré', () => { | |
| 25 | + const store = setup(); | |
| 26 | + expect(store.places().length).toBe(SEED_PLACES.length); | |
| 27 | + }); | |
| 28 | + | |
| 29 | + it('ignore un stockage corrompu', () => { | |
| 30 | + const store = setup(memoryStorage({ [STORAGE_KEY]: '{pas du json' })); | |
| 31 | + expect(store.places().length).toBe(SEED_PLACES.length); | |
| 32 | + }); | |
| 33 | + | |
| 34 | + it('ne reprend que les lieux valides du stockage', () => { | |
| 35 | + const valid = { ...annecy, id: 'a' }; | |
| 36 | + const store = setup(memoryStorage({ [STORAGE_KEY]: JSON.stringify([valid, { id: 'b' }, 42]) })); | |
| 37 | + expect(store.places().map((place) => place.id)).toEqual(['a']); | |
| 38 | + }); | |
| 39 | + | |
| 40 | + it('fonctionne sans stockage disponible', () => { | |
| 41 | + const store = setup(null); | |
| 42 | + store.add(annecy); | |
| 43 | + expect(store.places().some((place) => place.name === 'Annecy')).toBeTrue(); | |
| 44 | + }); | |
| 45 | + | |
| 46 | + it('ajoute un lieu et le persiste', () => { | |
| 47 | + const storage = memoryStorage(); | |
| 48 | + const store = setup(storage); | |
| 49 | + | |
| 50 | + const created = store.add(annecy); | |
| 51 | + | |
| 52 | + expect(created.id).toBeTruthy(); | |
| 53 | + expect(store.places()).toContain(created); | |
| 54 | + expect(JSON.parse(storage.dump()[STORAGE_KEY] ?? '[]')).toContain(jasmine.objectContaining({ name: 'Annecy' })); | |
| 55 | + }); | |
| 56 | + | |
| 57 | + it('trie les lieux visités du plus récent au plus ancien', () => { | |
| 58 | + const store = setup(); | |
| 59 | + store.add({ ...annecy, name: 'Récent', visitedOn: '2025-01-01' }); | |
| 60 | + expect(store.visited()[0]?.name).toBe('Récent'); | |
| 61 | + }); | |
| 62 | + | |
| 63 | + it('trie la liste de souhaits par priorité', () => { | |
| 64 | + const store = setup(memoryStorage({ [STORAGE_KEY]: '[]' })); | |
| 65 | + const wish: NewPlace = { ...annecy, status: 'wishlist', visitedOn: undefined, rating: undefined }; | |
| 66 | + store.add({ ...wish, name: 'Plus tard', priority: 'low' }); | |
| 67 | + store.add({ ...wish, name: 'Vite', priority: 'high' }); | |
| 68 | + | |
| 69 | + expect(store.wishlist().map((place) => place.name)).toEqual(['Vite', 'Plus tard']); | |
| 70 | + }); | |
| 71 | + | |
| 72 | + it('transforme un souhait en lieu visité', () => { | |
| 73 | + const store = setup(memoryStorage({ [STORAGE_KEY]: '[]' })); | |
| 74 | + const wish = store.add({ ...annecy, status: 'wishlist', priority: 'high', visitedOn: undefined, rating: undefined }); | |
| 75 | + | |
| 76 | + store.markVisited(wish.id, '2025-06-01', 5); | |
| 77 | + | |
| 78 | + expect(store.wishlist().length).toBe(0); | |
| 79 | + expect(store.visited()[0]).toEqual( | |
| 80 | + jasmine.objectContaining({ id: wish.id, status: 'visited', visitedOn: '2025-06-01', rating: 5 }), | |
| 81 | + ); | |
| 82 | + }); | |
| 83 | + | |
| 84 | + it('supprime un lieu', () => { | |
| 85 | + const store = setup(); | |
| 86 | + const created = store.add(annecy); | |
| 87 | + store.remove(created.id); | |
| 88 | + expect(store.places()).not.toContain(created); | |
| 89 | + }); | |
| 90 | + | |
| 91 | + it('calcule les statistiques', () => { | |
| 92 | + const store = setup(memoryStorage({ [STORAGE_KEY]: '[]' })); | |
| 93 | + store.add({ ...annecy, country: 'France', rating: 5 }); | |
| 94 | + store.add({ ...annecy, name: 'Chamonix', country: ' france ', rating: 4 }); | |
| 95 | + store.add({ ...annecy, name: 'Lisbonne', country: 'Portugal', rating: undefined }); | |
| 96 | + store.add({ ...annecy, name: 'Kyoto', country: 'Japon', status: 'wishlist' }); | |
| 97 | + | |
| 98 | + expect(store.stats()).toEqual({ visitedCount: 3, wishlistCount: 1, countryCount: 2, averageRating: 4.5 }); | |
| 99 | + }); | |
| 100 | + | |
| 101 | + it('n’a pas de note moyenne sans note', () => { | |
| 102 | + const store = setup(memoryStorage({ [STORAGE_KEY]: '[]' })); | |
| 103 | + expect(store.stats().averageRating).toBeNull(); | |
| 104 | + }); | |
| 105 | + | |
| 106 | + it('revient aux lieux d’exemple', () => { | |
| 107 | + const store = setup(); | |
| 108 | + store.add(annecy); | |
| 109 | + store.reset(); | |
| 110 | + expect(store.places().length).toBe(SEED_PLACES.length); | |
| 111 | + }); | |
| 112 | +}); | |
| new file mode 100644 | |||
| @@ -0,0 +1,112 @@ | |||
| 1 | +import { TestBed } from '@angular/core/testing'; | ||
| 2 | +import { SEED_PLACES } from '../data/seed-places'; | ||
| 3 | +import { NewPlace } from '../models/place.model'; | ||
| 4 | +import { memoryStorage } from '../testing/memory-storage'; | ||
| 5 | +import { STORAGE_KEY, TRAVEL_STORAGE, TravelStore } from './travel-store.service'; | ||
| 6 | + | ||
| 7 | +const annecy: NewPlace = { | ||
| 8 | + name: 'Annecy', | ||
| 9 | + country: 'France', | ||
| 10 | + category: 'city', | ||
| 11 | + status: 'visited', | ||
| 12 | + lat: 45.8992, | ||
| 13 | + lng: 6.1294, | ||
| 14 | + visitedOn: '2024-08-01', | ||
| 15 | + rating: 4, | ||
| 16 | +}; | ||
| 17 | + | ||
| 18 | +describe('TravelStore', () => { | ||
| 19 | + function setup(storage: ReturnType<typeof memoryStorage> | null = memoryStorage()) { | ||
| 20 | + TestBed.configureTestingModule({ providers: [{ provide: TRAVEL_STORAGE, useValue: storage }] }); | ||
| 21 | + return TestBed.inject(TravelStore); | ||
| 22 | + } | ||
| 23 | + | ||
| 24 | + it('démarre avec les lieux d’exemple quand rien n’est enregistré', () => { | ||
| 25 | + const store = setup(); | ||
| 26 | + expect(store.places().length).toBe(SEED_PLACES.length); | ||
| 27 | + }); | ||
| 28 | + | ||
| 29 | + it('ignore un stockage corrompu', () => { | ||
| 30 | + const store = setup(memoryStorage({ [STORAGE_KEY]: '{pas du json' })); | ||
| 31 | + expect(store.places().length).toBe(SEED_PLACES.length); | ||
| 32 | + }); | ||
| 33 | + | ||
| 34 | + it('ne reprend que les lieux valides du stockage', () => { | ||
| 35 | + const valid = { ...annecy, id: 'a' }; | ||
| 36 | + const store = setup(memoryStorage({ [STORAGE_KEY]: JSON.stringify([valid, { id: 'b' }, 42]) })); | ||
| 37 | + expect(store.places().map((place) => place.id)).toEqual(['a']); | ||
| 38 | + }); | ||
| 39 | + | ||
| 40 | + it('fonctionne sans stockage disponible', () => { | ||
| 41 | + const store = setup(null); | ||
| 42 | + store.add(annecy); | ||
| 43 | + expect(store.places().some((place) => place.name === 'Annecy')).toBeTrue(); | ||
| 44 | + }); | ||
| 45 | + | ||
| 46 | + it('ajoute un lieu et le persiste', () => { | ||
| 47 | + const storage = memoryStorage(); | ||
| 48 | + const store = setup(storage); | ||
| 49 | + | ||
| 50 | + const created = store.add(annecy); | ||
| 51 | + | ||
| 52 | + expect(created.id).toBeTruthy(); | ||
| 53 | + expect(store.places()).toContain(created); | ||
| 54 | + expect(JSON.parse(storage.dump()[STORAGE_KEY] ?? '[]')).toContain(jasmine.objectContaining({ name: 'Annecy' })); | ||
| 55 | + }); | ||
| 56 | + | ||
| 57 | + it('trie les lieux visités du plus récent au plus ancien', () => { | ||
| 58 | + const store = setup(); | ||
| 59 | + store.add({ ...annecy, name: 'Récent', visitedOn: '2025-01-01' }); | ||
| 60 | + expect(store.visited()[0]?.name).toBe('Récent'); | ||
| 61 | + }); | ||
| 62 | + | ||
| 63 | + it('trie la liste de souhaits par priorité', () => { | ||
| 64 | + const store = setup(memoryStorage({ [STORAGE_KEY]: '[]' })); | ||
| 65 | + const wish: NewPlace = { ...annecy, status: 'wishlist', visitedOn: undefined, rating: undefined }; | ||
| 66 | + store.add({ ...wish, name: 'Plus tard', priority: 'low' }); | ||
| 67 | + store.add({ ...wish, name: 'Vite', priority: 'high' }); | ||
| 68 | + | ||
| 69 | + expect(store.wishlist().map((place) => place.name)).toEqual(['Vite', 'Plus tard']); | ||
| 70 | + }); | ||
| 71 | + | ||
| 72 | + it('transforme un souhait en lieu visité', () => { | ||
| 73 | + const store = setup(memoryStorage({ [STORAGE_KEY]: '[]' })); | ||
| 74 | + const wish = store.add({ ...annecy, status: 'wishlist', priority: 'high', visitedOn: undefined, rating: undefined }); | ||
| 75 | + | ||
| 76 | + store.markVisited(wish.id, '2025-06-01', 5); | ||
| 77 | + | ||
| 78 | + expect(store.wishlist().length).toBe(0); | ||
| 79 | + expect(store.visited()[0]).toEqual( | ||
| 80 | + jasmine.objectContaining({ id: wish.id, status: 'visited', visitedOn: '2025-06-01', rating: 5 }), | ||
| 81 | + ); | ||
| 82 | + }); | ||
| 83 | + | ||
| 84 | + it('supprime un lieu', () => { | ||
| 85 | + const store = setup(); | ||
| 86 | + const created = store.add(annecy); | ||
| 87 | + store.remove(created.id); | ||
| 88 | + expect(store.places()).not.toContain(created); | ||
| 89 | + }); | ||
| 90 | + | ||
| 91 | + it('calcule les statistiques', () => { | ||
| 92 | + const store = setup(memoryStorage({ [STORAGE_KEY]: '[]' })); | ||
| 93 | + store.add({ ...annecy, country: 'France', rating: 5 }); | ||
| 94 | + store.add({ ...annecy, name: 'Chamonix', country: ' france ', rating: 4 }); | ||
| 95 | + store.add({ ...annecy, name: 'Lisbonne', country: 'Portugal', rating: undefined }); | ||
| 96 | + store.add({ ...annecy, name: 'Kyoto', country: 'Japon', status: 'wishlist' }); | ||
| 97 | + | ||
| 98 | + expect(store.stats()).toEqual({ visitedCount: 3, wishlistCount: 1, countryCount: 2, averageRating: 4.5 }); | ||
| 99 | + }); | ||
| 100 | + | ||
| 101 | + it('n’a pas de note moyenne sans note', () => { | ||
| 102 | + const store = setup(memoryStorage({ [STORAGE_KEY]: '[]' })); | ||
| 103 | + expect(store.stats().averageRating).toBeNull(); | ||
| 104 | + }); | ||
| 105 | + | ||
| 106 | + it('revient aux lieux d’exemple', () => { | ||
| 107 | + const store = setup(); | ||
| 108 | + store.add(annecy); | ||
| 109 | + store.reset(); | ||
| 110 | + expect(store.places().length).toBe(SEED_PLACES.length); | ||
| 111 | + }); | ||
| 112 | +}); | ||
added
src/app/travel/services/travel-store.service.ts +138 -0 | new file mode 100644 | ||
| @@ -0,0 +1,138 @@ | ||
| 1 | +import { Inject, Injectable, InjectionToken, computed, signal } from '@angular/core'; | |
| 2 | +import { SEED_PLACES } from '../data/seed-places'; | |
| 3 | +import { NewPlace, Place, PlacePriority } from '../models/place.model'; | |
| 4 | + | |
| 5 | +type TravelStorage = Pick<Storage, 'getItem' | 'setItem'>; | |
| 6 | + | |
| 7 | +/** Stockage utilisé pour conserver les lieux. `null` si le navigateur le refuse (navigation privée, par exemple). */ | |
| 8 | +export const TRAVEL_STORAGE = new InjectionToken<TravelStorage | null>('TRAVEL_STORAGE', { | |
| 9 | + providedIn: 'root', | |
| 10 | + factory: () => { | |
| 11 | + try { | |
| 12 | + return globalThis.localStorage ?? null; | |
| 13 | + } catch { | |
| 14 | + return null; | |
| 15 | + } | |
| 16 | + }, | |
| 17 | +}); | |
| 18 | + | |
| 19 | +export const STORAGE_KEY = 'roadtrip.places.v1'; | |
| 20 | + | |
| 21 | +export interface TravelStats { | |
| 22 | + visitedCount: number; | |
| 23 | + wishlistCount: number; | |
| 24 | + countryCount: number; | |
| 25 | + averageRating: number | null; | |
| 26 | +} | |
| 27 | + | |
| 28 | +const PRIORITY_RANK: Record<PlacePriority, number> = { high: 0, medium: 1, low: 2 }; | |
| 29 | + | |
| 30 | +@Injectable({ providedIn: 'root' }) | |
| 31 | +export class TravelStore { | |
| 32 | + private readonly state = signal<readonly Place[]>([]); | |
| 33 | + | |
| 34 | + readonly places = this.state.asReadonly(); | |
| 35 | + | |
| 36 | + /** Lieux visités, du plus récent au plus ancien. */ | |
| 37 | + readonly visited = computed(() => | |
| 38 | + this.state() | |
| 39 | + .filter((place) => place.status === 'visited') | |
| 40 | + .sort((a, b) => (b.visitedOn ?? '').localeCompare(a.visitedOn ?? '')), | |
| 41 | + ); | |
| 42 | + | |
| 43 | + /** Liste de souhaits, par priorité puis par ordre alphabétique. */ | |
| 44 | + readonly wishlist = computed(() => | |
| 45 | + this.state() | |
| 46 | + .filter((place) => place.status === 'wishlist') | |
| 47 | + .sort( | |
| 48 | + (a, b) => | |
| 49 | + PRIORITY_RANK[a.priority ?? 'medium'] - PRIORITY_RANK[b.priority ?? 'medium'] || | |
| 50 | + a.name.localeCompare(b.name, 'fr'), | |
| 51 | + ), | |
| 52 | + ); | |
| 53 | + | |
| 54 | + readonly stats = computed<TravelStats>(() => { | |
| 55 | + const visited = this.visited(); | |
| 56 | + const ratings = visited.map((place) => place.rating).filter((rating): rating is number => rating !== undefined); | |
| 57 | + const average = ratings.reduce((sum, rating) => sum + rating, 0) / (ratings.length || 1); | |
| 58 | + | |
| 59 | + return { | |
| 60 | + visitedCount: visited.length, | |
| 61 | + wishlistCount: this.wishlist().length, | |
| 62 | + countryCount: new Set(visited.map((place) => place.country.trim().toLocaleLowerCase('fr'))).size, | |
| 63 | + averageRating: ratings.length ? Math.round(average * 10) / 10 : null, | |
| 64 | + }; | |
| 65 | + }); | |
| 66 | + | |
| 67 | + constructor(@Inject(TRAVEL_STORAGE) private storage: TravelStorage | null) { | |
| 68 | + this.state.set(this.restore()); | |
| 69 | + } | |
| 70 | + | |
| 71 | + add(place: NewPlace): Place { | |
| 72 | + const created: Place = { ...place, id: newId() }; | |
| 73 | + this.commit([...this.state(), created]); | |
| 74 | + return created; | |
| 75 | + } | |
| 76 | + | |
| 77 | + /** Transforme un souhait en lieu visité. */ | |
| 78 | + markVisited(id: string, visitedOn: string, rating?: number): void { | |
| 79 | + this.commit( | |
| 80 | + this.state().map( | |
| 81 | + (place): Place => | |
| 82 | + place.id === id ? { ...place, status: 'visited', visitedOn, rating, priority: undefined } : place, | |
| 83 | + ), | |
| 84 | + ); | |
| 85 | + } | |
| 86 | + | |
| 87 | + remove(id: string): void { | |
| 88 | + this.commit(this.state().filter((place) => place.id !== id)); | |
| 89 | + } | |
| 90 | + | |
| 91 | + /** Revient aux lieux d'exemple. */ | |
| 92 | + reset(): void { | |
| 93 | + this.commit(SEED_PLACES); | |
| 94 | + } | |
| 95 | + | |
| 96 | + private commit(next: readonly Place[]): void { | |
| 97 | + this.state.set(next); | |
| 98 | + try { | |
| 99 | + this.storage?.setItem(STORAGE_KEY, JSON.stringify(next)); | |
| 100 | + } catch { | |
| 101 | + // Quota dépassé ou stockage indisponible : l'état reste disponible en mémoire. | |
| 102 | + } | |
| 103 | + } | |
| 104 | + | |
| 105 | + private restore(): readonly Place[] { | |
| 106 | + try { | |
| 107 | + const raw = this.storage?.getItem(STORAGE_KEY); | |
| 108 | + if (raw === null || raw === undefined) { | |
| 109 | + return SEED_PLACES; | |
| 110 | + } | |
| 111 | + const parsed: unknown = JSON.parse(raw); | |
| 112 | + return Array.isArray(parsed) ? parsed.filter(isPlace) : SEED_PLACES; | |
| 113 | + } catch { | |
| 114 | + return SEED_PLACES; | |
| 115 | + } | |
| 116 | + } | |
| 117 | +} | |
| 118 | + | |
| 119 | +function isPlace(value: unknown): value is Place { | |
| 120 | + if (typeof value !== 'object' || value === null) { | |
| 121 | + return false; | |
| 122 | + } | |
| 123 | + const candidate = value as Record<string, unknown>; | |
| 124 | + return ( | |
| 125 | + typeof candidate['id'] === 'string' && | |
| 126 | + typeof candidate['name'] === 'string' && | |
| 127 | + typeof candidate['country'] === 'string' && | |
| 128 | + (candidate['status'] === 'visited' || candidate['status'] === 'wishlist') && | |
| 129 | + typeof candidate['lat'] === 'number' && | |
| 130 | + typeof candidate['lng'] === 'number' | |
| 131 | + ); | |
| 132 | +} | |
| 133 | + | |
| 134 | +function newId(): string { | |
| 135 | + return ( | |
| 136 | + globalThis.crypto?.randomUUID?.() ?? `place-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}` | |
| 137 | + ); | |
| 138 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,138 @@ | |||
| 1 | +import { Inject, Injectable, InjectionToken, computed, signal } from '@angular/core'; | ||
| 2 | +import { SEED_PLACES } from '../data/seed-places'; | ||
| 3 | +import { NewPlace, Place, PlacePriority } from '../models/place.model'; | ||
| 4 | + | ||
| 5 | +type TravelStorage = Pick<Storage, 'getItem' | 'setItem'>; | ||
| 6 | + | ||
| 7 | +/** Stockage utilisé pour conserver les lieux. `null` si le navigateur le refuse (navigation privée, par exemple). */ | ||
| 8 | +export const TRAVEL_STORAGE = new InjectionToken<TravelStorage | null>('TRAVEL_STORAGE', { | ||
| 9 | + providedIn: 'root', | ||
| 10 | + factory: () => { | ||
| 11 | + try { | ||
| 12 | + return globalThis.localStorage ?? null; | ||
| 13 | + } catch { | ||
| 14 | + return null; | ||
| 15 | + } | ||
| 16 | + }, | ||
| 17 | +}); | ||
| 18 | + | ||
| 19 | +export const STORAGE_KEY = 'roadtrip.places.v1'; | ||
| 20 | + | ||
| 21 | +export interface TravelStats { | ||
| 22 | + visitedCount: number; | ||
| 23 | + wishlistCount: number; | ||
| 24 | + countryCount: number; | ||
| 25 | + averageRating: number | null; | ||
| 26 | +} | ||
| 27 | + | ||
| 28 | +const PRIORITY_RANK: Record<PlacePriority, number> = { high: 0, medium: 1, low: 2 }; | ||
| 29 | + | ||
| 30 | +@Injectable({ providedIn: 'root' }) | ||
| 31 | +export class TravelStore { | ||
| 32 | + private readonly state = signal<readonly Place[]>([]); | ||
| 33 | + | ||
| 34 | + readonly places = this.state.asReadonly(); | ||
| 35 | + | ||
| 36 | + /** Lieux visités, du plus récent au plus ancien. */ | ||
| 37 | + readonly visited = computed(() => | ||
| 38 | + this.state() | ||
| 39 | + .filter((place) => place.status === 'visited') | ||
| 40 | + .sort((a, b) => (b.visitedOn ?? '').localeCompare(a.visitedOn ?? '')), | ||
| 41 | + ); | ||
| 42 | + | ||
| 43 | + /** Liste de souhaits, par priorité puis par ordre alphabétique. */ | ||
| 44 | + readonly wishlist = computed(() => | ||
| 45 | + this.state() | ||
| 46 | + .filter((place) => place.status === 'wishlist') | ||
| 47 | + .sort( | ||
| 48 | + (a, b) => | ||
| 49 | + PRIORITY_RANK[a.priority ?? 'medium'] - PRIORITY_RANK[b.priority ?? 'medium'] || | ||
| 50 | + a.name.localeCompare(b.name, 'fr'), | ||
| 51 | + ), | ||
| 52 | + ); | ||
| 53 | + | ||
| 54 | + readonly stats = computed<TravelStats>(() => { | ||
| 55 | + const visited = this.visited(); | ||
| 56 | + const ratings = visited.map((place) => place.rating).filter((rating): rating is number => rating !== undefined); | ||
| 57 | + const average = ratings.reduce((sum, rating) => sum + rating, 0) / (ratings.length || 1); | ||
| 58 | + | ||
| 59 | + return { | ||
| 60 | + visitedCount: visited.length, | ||
| 61 | + wishlistCount: this.wishlist().length, | ||
| 62 | + countryCount: new Set(visited.map((place) => place.country.trim().toLocaleLowerCase('fr'))).size, | ||
| 63 | + averageRating: ratings.length ? Math.round(average * 10) / 10 : null, | ||
| 64 | + }; | ||
| 65 | + }); | ||
| 66 | + | ||
| 67 | + constructor(@Inject(TRAVEL_STORAGE) private storage: TravelStorage | null) { | ||
| 68 | + this.state.set(this.restore()); | ||
| 69 | + } | ||
| 70 | + | ||
| 71 | + add(place: NewPlace): Place { | ||
| 72 | + const created: Place = { ...place, id: newId() }; | ||
| 73 | + this.commit([...this.state(), created]); | ||
| 74 | + return created; | ||
| 75 | + } | ||
| 76 | + | ||
| 77 | + /** Transforme un souhait en lieu visité. */ | ||
| 78 | + markVisited(id: string, visitedOn: string, rating?: number): void { | ||
| 79 | + this.commit( | ||
| 80 | + this.state().map( | ||
| 81 | + (place): Place => | ||
| 82 | + place.id === id ? { ...place, status: 'visited', visitedOn, rating, priority: undefined } : place, | ||
| 83 | + ), | ||
| 84 | + ); | ||
| 85 | + } | ||
| 86 | + | ||
| 87 | + remove(id: string): void { | ||
| 88 | + this.commit(this.state().filter((place) => place.id !== id)); | ||
| 89 | + } | ||
| 90 | + | ||
| 91 | + /** Revient aux lieux d'exemple. */ | ||
| 92 | + reset(): void { | ||
| 93 | + this.commit(SEED_PLACES); | ||
| 94 | + } | ||
| 95 | + | ||
| 96 | + private commit(next: readonly Place[]): void { | ||
| 97 | + this.state.set(next); | ||
| 98 | + try { | ||
| 99 | + this.storage?.setItem(STORAGE_KEY, JSON.stringify(next)); | ||
| 100 | + } catch { | ||
| 101 | + // Quota dépassé ou stockage indisponible : l'état reste disponible en mémoire. | ||
| 102 | + } | ||
| 103 | + } | ||
| 104 | + | ||
| 105 | + private restore(): readonly Place[] { | ||
| 106 | + try { | ||
| 107 | + const raw = this.storage?.getItem(STORAGE_KEY); | ||
| 108 | + if (raw === null || raw === undefined) { | ||
| 109 | + return SEED_PLACES; | ||
| 110 | + } | ||
| 111 | + const parsed: unknown = JSON.parse(raw); | ||
| 112 | + return Array.isArray(parsed) ? parsed.filter(isPlace) : SEED_PLACES; | ||
| 113 | + } catch { | ||
| 114 | + return SEED_PLACES; | ||
| 115 | + } | ||
| 116 | + } | ||
| 117 | +} | ||
| 118 | + | ||
| 119 | +function isPlace(value: unknown): value is Place { | ||
| 120 | + if (typeof value !== 'object' || value === null) { | ||
| 121 | + return false; | ||
| 122 | + } | ||
| 123 | + const candidate = value as Record<string, unknown>; | ||
| 124 | + return ( | ||
| 125 | + typeof candidate['id'] === 'string' && | ||
| 126 | + typeof candidate['name'] === 'string' && | ||
| 127 | + typeof candidate['country'] === 'string' && | ||
| 128 | + (candidate['status'] === 'visited' || candidate['status'] === 'wishlist') && | ||
| 129 | + typeof candidate['lat'] === 'number' && | ||
| 130 | + typeof candidate['lng'] === 'number' | ||
| 131 | + ); | ||
| 132 | +} | ||
| 133 | + | ||
| 134 | +function newId(): string { | ||
| 135 | + return ( | ||
| 136 | + globalThis.crypto?.randomUUID?.() ?? `place-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}` | ||
| 137 | + ); | ||
| 138 | +} | ||
added
src/app/travel/testing/memory-storage.ts +11 -0 | new file mode 100644 | ||
| @@ -0,0 +1,11 @@ | ||
| 1 | +/** Stockage en mémoire pour les tests : évite de toucher au localStorage du navigateur. */ | |
| 2 | +export function memoryStorage(initial: Record<string, string> = {}) { | |
| 3 | + const data = new Map(Object.entries(initial)); | |
| 4 | + return { | |
| 5 | + getItem: (key: string): string | null => data.get(key) ?? null, | |
| 6 | + setItem: (key: string, value: string): void => { | |
| 7 | + data.set(key, value); | |
| 8 | + }, | |
| 9 | + dump: (): Record<string, string> => Object.fromEntries(data), | |
| 10 | + }; | |
| 11 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,11 @@ | |||
| 1 | +/** Stockage en mémoire pour les tests : évite de toucher au localStorage du navigateur. */ | ||
| 2 | +export function memoryStorage(initial: Record<string, string> = {}) { | ||
| 3 | + const data = new Map(Object.entries(initial)); | ||
| 4 | + return { | ||
| 5 | + getItem: (key: string): string | null => data.get(key) ?? null, | ||
| 6 | + setItem: (key: string, value: string): void => { | ||
| 7 | + data.set(key, value); | ||
| 8 | + }, | ||
| 9 | + dump: (): Record<string, string> => Object.fromEntries(data), | ||
| 10 | + }; | ||
| 11 | +} | ||
added
src/app/travel/utils/date.ts +11 -0 | new file mode 100644 | ||
| @@ -0,0 +1,11 @@ | ||
| 1 | +/** Date du jour au format ISO local (yyyy-MM-dd), sans décalage de fuseau horaire. */ | |
| 2 | +export function todayIso(now: Date = new Date()): string { | |
| 3 | + const month = String(now.getMonth() + 1).padStart(2, '0'); | |
| 4 | + const day = String(now.getDate()).padStart(2, '0'); | |
| 5 | + return `${now.getFullYear()}-${month}-${day}`; | |
| 6 | +} | |
| 7 | + | |
| 8 | +/** Formate une date ISO (yyyy-MM-dd) en français, par exemple « 14 mai 2023 ». */ | |
| 9 | +export function formatIsoDate(iso: string): string { | |
| 10 | + return new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' }).format(new Date(`${iso}T12:00:00`)); | |
| 11 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,11 @@ | |||
| 1 | +/** Date du jour au format ISO local (yyyy-MM-dd), sans décalage de fuseau horaire. */ | ||
| 2 | +export function todayIso(now: Date = new Date()): string { | ||
| 3 | + const month = String(now.getMonth() + 1).padStart(2, '0'); | ||
| 4 | + const day = String(now.getDate()).padStart(2, '0'); | ||
| 5 | + return `${now.getFullYear()}-${month}-${day}`; | ||
| 6 | +} | ||
| 7 | + | ||
| 8 | +/** Formate une date ISO (yyyy-MM-dd) en français, par exemple « 14 mai 2023 ». */ | ||
| 9 | +export function formatIsoDate(iso: string): string { | ||
| 10 | + return new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' }).format(new Date(`${iso}T12:00:00`)); | ||
| 11 | +} | ||
added
src/app/travel/utils/not-in-future.validator.ts +10 -0 | new file mode 100644 | ||
| @@ -0,0 +1,10 @@ | ||
| 1 | +import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'; | |
| 2 | +import { todayIso } from './date'; | |
| 3 | + | |
| 4 | +/** Refuse une date ISO (yyyy-MM-dd) postérieure à aujourd'hui. */ | |
| 5 | +export function notInFuture(today: () => string = todayIso): ValidatorFn { | |
| 6 | + return (control: AbstractControl): ValidationErrors | null => { | |
| 7 | + const value = control.value as string | null; | |
| 8 | + return value && value > today() ? { future: true } : null; | |
| 9 | + }; | |
| 10 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,10 @@ | |||
| 1 | +import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'; | ||
| 2 | +import { todayIso } from './date'; | ||
| 3 | + | ||
| 4 | +/** Refuse une date ISO (yyyy-MM-dd) postérieure à aujourd'hui. */ | ||
| 5 | +export function notInFuture(today: () => string = todayIso): ValidatorFn { | ||
| 6 | + return (control: AbstractControl): ValidationErrors | null => { | ||
| 7 | + const value = control.value as string | null; | ||
| 8 | + return value && value > today() ? { future: true } : null; | ||
| 9 | + }; | ||
| 10 | +} | ||
modified
src/index.html +1 -1 | @@ -1,5 +1,5 @@ | ||
| 1 | 1 | <!doctype html> |
| 2 | -<html lang="en"> | |
| 2 | +<html lang="fr"> | |
| 3 | 3 | <head> |
| 4 | 4 | <meta charset="utf-8"> |
| 5 | 5 | <title>RoadTrip</title> |
| @@ -1,5 +1,5 @@ | |||
| 1 | <!doctype html> | 1 | <!doctype html> |
| 2 | -<html lang="en"> | 2 | +<html lang="fr"> |
| 3 | <head> | 3 | <head> |
| 4 | <meta charset="utf-8"> | 4 | <meta charset="utf-8"> |
| 5 | <title>RoadTrip</title> | 5 | <title>RoadTrip</title> |
modified
src/styles.css +390 -1 | @@ -1 +1,390 @@ | ||
| 1 | -/* You can add global styles to this file, and also import other style files */ | |
| 1 | +@import 'leaflet/dist/leaflet.css'; | |
| 2 | + | |
| 3 | +:root { | |
| 4 | + color-scheme: light dark; | |
| 5 | + | |
| 6 | + --bg: #f5f1ea; | |
| 7 | + --surface: #fffdf9; | |
| 8 | + --ink: #1d2a2f; | |
| 9 | + --muted: #5a686d; | |
| 10 | + --line: #e4ddd0; | |
| 11 | + --visited: #0f766e; | |
| 12 | + --visited-soft: #d9efea; | |
| 13 | + --wish: #b8400c; | |
| 14 | + --wish-soft: #fde6d8; | |
| 15 | + --danger: #b42318; | |
| 16 | + --on-accent: #ffffff; | |
| 17 | + --shadow: 0 1px 2px rgb(29 42 47 / 0.06), 0 10px 28px rgb(29 42 47 / 0.07); | |
| 18 | + | |
| 19 | + --radius: 14px; | |
| 20 | + --gutter: clamp(1rem, 4vw, 2.5rem); | |
| 21 | + --font-body: 'Avenir Next', 'Segoe UI', system-ui, -apple-system, sans-serif; | |
| 22 | + --font-display: 'Iowan Old Style', 'Palatino Linotype', Palatino, Georgia, serif; | |
| 23 | +} | |
| 24 | + | |
| 25 | +@media (prefers-color-scheme: dark) { | |
| 26 | + :root { | |
| 27 | + --bg: #11181b; | |
| 28 | + --surface: #182226; | |
| 29 | + --ink: #eef2f0; | |
| 30 | + --muted: #a3b3b6; | |
| 31 | + --line: #2b393e; | |
| 32 | + --visited: #46c6b7; | |
| 33 | + --visited-soft: #17332f; | |
| 34 | + --wish: #f5a06c; | |
| 35 | + --wish-soft: #3a2619; | |
| 36 | + --danger: #ff8f84; | |
| 37 | + --on-accent: #0b1416; | |
| 38 | + --shadow: 0 1px 2px rgb(0 0 0 / 0.4); | |
| 39 | + } | |
| 40 | +} | |
| 41 | + | |
| 42 | +*, | |
| 43 | +*::before, | |
| 44 | +*::after { | |
| 45 | + box-sizing: border-box; | |
| 46 | +} | |
| 47 | + | |
| 48 | +body { | |
| 49 | + margin: 0; | |
| 50 | + background: var(--bg); | |
| 51 | + color: var(--ink); | |
| 52 | + font-family: var(--font-body); | |
| 53 | + line-height: 1.5; | |
| 54 | +} | |
| 55 | + | |
| 56 | +h1, | |
| 57 | +h2 { | |
| 58 | + margin: 0; | |
| 59 | + font-family: var(--font-display); | |
| 60 | + line-height: 1.2; | |
| 61 | +} | |
| 62 | + | |
| 63 | +h1 { | |
| 64 | + font-size: clamp(1.7rem, 4vw, 2.4rem); | |
| 65 | +} | |
| 66 | + | |
| 67 | +h2 { | |
| 68 | + font-size: 1.25rem; | |
| 69 | +} | |
| 70 | + | |
| 71 | +a { | |
| 72 | + color: var(--visited); | |
| 73 | +} | |
| 74 | + | |
| 75 | +:focus-visible { | |
| 76 | + outline: 3px solid color-mix(in srgb, var(--visited) 60%, transparent); | |
| 77 | + outline-offset: 2px; | |
| 78 | +} | |
| 79 | + | |
| 80 | +/* ---------- Mise en page ---------- */ | |
| 81 | + | |
| 82 | +.page { | |
| 83 | + max-width: 76rem; | |
| 84 | + margin: 0 auto; | |
| 85 | + padding: 1.75rem var(--gutter) 1rem; | |
| 86 | +} | |
| 87 | + | |
| 88 | +.page__header { | |
| 89 | + margin-bottom: 1.5rem; | |
| 90 | +} | |
| 91 | + | |
| 92 | +.eyebrow { | |
| 93 | + margin: 0 0 0.25rem; | |
| 94 | + color: var(--wish); | |
| 95 | + font-size: 0.8rem; | |
| 96 | + font-weight: 700; | |
| 97 | + letter-spacing: 0.08em; | |
| 98 | + text-transform: uppercase; | |
| 99 | +} | |
| 100 | + | |
| 101 | +.layout { | |
| 102 | + display: grid; | |
| 103 | + gap: 1.25rem; | |
| 104 | + grid-template-columns: minmax(0, 1fr); | |
| 105 | + align-items: start; | |
| 106 | +} | |
| 107 | + | |
| 108 | +@media (min-width: 62rem) { | |
| 109 | + .layout { | |
| 110 | + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); | |
| 111 | + } | |
| 112 | + | |
| 113 | + .layout--map-first { | |
| 114 | + grid-template-columns: minmax(0, 7fr) minmax(0, 5fr); | |
| 115 | + } | |
| 116 | + | |
| 117 | + .sticky { | |
| 118 | + position: sticky; | |
| 119 | + top: 5rem; | |
| 120 | + } | |
| 121 | +} | |
| 122 | + | |
| 123 | +.map-slot { | |
| 124 | + height: clamp(320px, 55vh, 560px); | |
| 125 | + margin-top: 0.75rem; | |
| 126 | +} | |
| 127 | + | |
| 128 | +/* ---------- Cartes et statistiques ---------- */ | |
| 129 | + | |
| 130 | +.card { | |
| 131 | + padding: 1.25rem; | |
| 132 | + border: 1px solid var(--line); | |
| 133 | + border-radius: var(--radius); | |
| 134 | + background: var(--surface); | |
| 135 | + box-shadow: var(--shadow); | |
| 136 | +} | |
| 137 | + | |
| 138 | +.card__head { | |
| 139 | + display: flex; | |
| 140 | + flex-wrap: wrap; | |
| 141 | + align-items: baseline; | |
| 142 | + justify-content: space-between; | |
| 143 | + gap: 0.5rem; | |
| 144 | +} | |
| 145 | + | |
| 146 | +.stat-grid { | |
| 147 | + display: grid; | |
| 148 | + gap: 1rem; | |
| 149 | + grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr)); | |
| 150 | + margin: 0 0 1.25rem; | |
| 151 | + padding: 0; | |
| 152 | + list-style: none; | |
| 153 | +} | |
| 154 | + | |
| 155 | +.stat { | |
| 156 | + display: flex; | |
| 157 | + flex-direction: column; | |
| 158 | +} | |
| 159 | + | |
| 160 | +.stat__value { | |
| 161 | + color: var(--visited); | |
| 162 | + font-family: var(--font-display); | |
| 163 | + font-size: 2.4rem; | |
| 164 | + font-weight: 700; | |
| 165 | + line-height: 1; | |
| 166 | +} | |
| 167 | + | |
| 168 | +.stat__label, | |
| 169 | +.muted, | |
| 170 | +.hint { | |
| 171 | + color: var(--muted); | |
| 172 | +} | |
| 173 | + | |
| 174 | +.hint { | |
| 175 | + margin: -0.25rem 0 0; | |
| 176 | + font-size: 0.85rem; | |
| 177 | +} | |
| 178 | + | |
| 179 | +.legend { | |
| 180 | + display: flex; | |
| 181 | + gap: 1rem; | |
| 182 | + margin: 0; | |
| 183 | + padding: 0; | |
| 184 | + color: var(--muted); | |
| 185 | + font-size: 0.85rem; | |
| 186 | + list-style: none; | |
| 187 | +} | |
| 188 | + | |
| 189 | +.legend__dot { | |
| 190 | + display: inline-block; | |
| 191 | + width: 0.8rem; | |
| 192 | + height: 0.8rem; | |
| 193 | + margin-right: 0.35rem; | |
| 194 | + border-radius: 50%; | |
| 195 | + vertical-align: -0.1rem; | |
| 196 | +} | |
| 197 | + | |
| 198 | +.legend__dot--visited { | |
| 199 | + background: var(--visited); | |
| 200 | +} | |
| 201 | + | |
| 202 | +.legend__dot--wishlist { | |
| 203 | + border: 2px dashed var(--wish); | |
| 204 | +} | |
| 205 | + | |
| 206 | +.empty, | |
| 207 | +.notice { | |
| 208 | + margin: 1rem 0; | |
| 209 | +} | |
| 210 | + | |
| 211 | +.notice:empty { | |
| 212 | + display: none; | |
| 213 | +} | |
| 214 | + | |
| 215 | +/* ---------- Listes ---------- */ | |
| 216 | + | |
| 217 | +.timeline, | |
| 218 | +.wish-list { | |
| 219 | + margin: 1rem 0 0; | |
| 220 | + padding: 0; | |
| 221 | + list-style: none; | |
| 222 | +} | |
| 223 | + | |
| 224 | +.timeline__item, | |
| 225 | +.wish { | |
| 226 | + display: grid; | |
| 227 | + gap: 0.2rem; | |
| 228 | + padding: 0.85rem 0; | |
| 229 | + border-top: 1px solid var(--line); | |
| 230 | +} | |
| 231 | + | |
| 232 | +.timeline__main, | |
| 233 | +.timeline__meta, | |
| 234 | +.wish__head { | |
| 235 | + display: flex; | |
| 236 | + flex-wrap: wrap; | |
| 237 | + align-items: baseline; | |
| 238 | + justify-content: space-between; | |
| 239 | + gap: 0.25rem 0.75rem; | |
| 240 | +} | |
| 241 | + | |
| 242 | +.timeline__meta { | |
| 243 | + justify-content: flex-start; | |
| 244 | + color: var(--muted); | |
| 245 | + font-size: 0.9rem; | |
| 246 | +} | |
| 247 | + | |
| 248 | +.timeline__note, | |
| 249 | +.wish__note { | |
| 250 | + margin: 0.2rem 0 0; | |
| 251 | +} | |
| 252 | + | |
| 253 | +.badge { | |
| 254 | + padding: 0.1rem 0.6rem; | |
| 255 | + border-radius: 999px; | |
| 256 | + background: var(--visited-soft); | |
| 257 | + color: var(--visited); | |
| 258 | + font-size: 0.78rem; | |
| 259 | + font-weight: 700; | |
| 260 | +} | |
| 261 | + | |
| 262 | +.badge--high { | |
| 263 | + background: var(--wish-soft); | |
| 264 | + color: var(--wish); | |
| 265 | +} | |
| 266 | + | |
| 267 | +/* ---------- Boutons ---------- */ | |
| 268 | + | |
| 269 | +.btn { | |
| 270 | + display: inline-flex; | |
| 271 | + align-items: center; | |
| 272 | + justify-content: center; | |
| 273 | + padding: 0.65rem 1.1rem; | |
| 274 | + border: 1px solid var(--line); | |
| 275 | + border-radius: 10px; | |
| 276 | + background: var(--surface); | |
| 277 | + color: var(--ink); | |
| 278 | + font: inherit; | |
| 279 | + font-weight: 600; | |
| 280 | + text-decoration: none; | |
| 281 | + cursor: pointer; | |
| 282 | +} | |
| 283 | + | |
| 284 | +.btn:hover { | |
| 285 | + border-color: var(--visited); | |
| 286 | +} | |
| 287 | + | |
| 288 | +.btn--primary { | |
| 289 | + border-color: var(--visited); | |
| 290 | + background: var(--visited); | |
| 291 | + color: var(--on-accent); | |
| 292 | +} | |
| 293 | + | |
| 294 | +.btn--danger { | |
| 295 | + border-color: var(--danger); | |
| 296 | + color: var(--danger); | |
| 297 | +} | |
| 298 | + | |
| 299 | +.btn--sm { | |
| 300 | + padding: 0.4rem 0.8rem; | |
| 301 | + font-size: 0.88rem; | |
| 302 | +} | |
| 303 | + | |
| 304 | +.actions { | |
| 305 | + display: flex; | |
| 306 | + flex-wrap: wrap; | |
| 307 | + gap: 0.5rem; | |
| 308 | + margin-top: 0.6rem; | |
| 309 | +} | |
| 310 | + | |
| 311 | +/* ---------- Formulaires ---------- */ | |
| 312 | + | |
| 313 | +.form { | |
| 314 | + display: grid; | |
| 315 | + gap: 1.1rem; | |
| 316 | +} | |
| 317 | + | |
| 318 | +.row { | |
| 319 | + display: grid; | |
| 320 | + gap: 1rem; | |
| 321 | + grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); | |
| 322 | +} | |
| 323 | + | |
| 324 | +.field { | |
| 325 | + display: grid; | |
| 326 | + gap: 0.35rem; | |
| 327 | + align-content: start; | |
| 328 | +} | |
| 329 | + | |
| 330 | +.field label, | |
| 331 | +.segmented legend { | |
| 332 | + font-size: 0.9rem; | |
| 333 | + font-weight: 600; | |
| 334 | +} | |
| 335 | + | |
| 336 | +input[type='text'], | |
| 337 | +input[type='number'], | |
| 338 | +input[type='date'], | |
| 339 | +select, | |
| 340 | +textarea { | |
| 341 | + width: 100%; | |
| 342 | + padding: 0.65rem 0.8rem; | |
| 343 | + border: 1px solid var(--line); | |
| 344 | + border-radius: 10px; | |
| 345 | + background: var(--bg); | |
| 346 | + color: var(--ink); | |
| 347 | + font: inherit; | |
| 348 | +} | |
| 349 | + | |
| 350 | +input.ng-invalid.ng-touched, | |
| 351 | +select.ng-invalid.ng-touched, | |
| 352 | +textarea.ng-invalid.ng-touched { | |
| 353 | + border-color: var(--danger); | |
| 354 | +} | |
| 355 | + | |
| 356 | +.field__error { | |
| 357 | + margin: 0; | |
| 358 | + color: var(--danger); | |
| 359 | + font-size: 0.85rem; | |
| 360 | +} | |
| 361 | + | |
| 362 | +.segmented { | |
| 363 | + display: flex; | |
| 364 | + flex-wrap: wrap; | |
| 365 | + gap: 0.5rem; | |
| 366 | + margin: 0; | |
| 367 | + padding: 0; | |
| 368 | + border: 0; | |
| 369 | +} | |
| 370 | + | |
| 371 | +.segmented legend { | |
| 372 | + width: 100%; | |
| 373 | + margin-bottom: 0.35rem; | |
| 374 | + padding: 0; | |
| 375 | +} | |
| 376 | + | |
| 377 | +.segmented label { | |
| 378 | + display: inline-flex; | |
| 379 | + align-items: center; | |
| 380 | + gap: 0.5rem; | |
| 381 | + padding: 0.55rem 0.9rem; | |
| 382 | + border: 1px solid var(--line); | |
| 383 | + border-radius: 999px; | |
| 384 | + cursor: pointer; | |
| 385 | +} | |
| 386 | + | |
| 387 | +.segmented label:has(input:checked) { | |
| 388 | + border-color: var(--visited); | |
| 389 | + background: var(--visited-soft); | |
| 390 | +} | |
| @@ -1 +1,390 @@ | |||
| 1 | -/* You can add global styles to this file, and also import other style files */ | 1 | +@import 'leaflet/dist/leaflet.css'; |
| 2 | + | ||
| 3 | +:root { | ||
| 4 | + color-scheme: light dark; | ||
| 5 | + | ||
| 6 | + --bg: #f5f1ea; | ||
| 7 | + --surface: #fffdf9; | ||
| 8 | + --ink: #1d2a2f; | ||
| 9 | + --muted: #5a686d; | ||
| 10 | + --line: #e4ddd0; | ||
| 11 | + --visited: #0f766e; | ||
| 12 | + --visited-soft: #d9efea; | ||
| 13 | + --wish: #b8400c; | ||
| 14 | + --wish-soft: #fde6d8; | ||
| 15 | + --danger: #b42318; | ||
| 16 | + --on-accent: #ffffff; | ||
| 17 | + --shadow: 0 1px 2px rgb(29 42 47 / 0.06), 0 10px 28px rgb(29 42 47 / 0.07); | ||
| 18 | + | ||
| 19 | + --radius: 14px; | ||
| 20 | + --gutter: clamp(1rem, 4vw, 2.5rem); | ||
| 21 | + --font-body: 'Avenir Next', 'Segoe UI', system-ui, -apple-system, sans-serif; | ||
| 22 | + --font-display: 'Iowan Old Style', 'Palatino Linotype', Palatino, Georgia, serif; | ||
| 23 | +} | ||
| 24 | + | ||
| 25 | +@media (prefers-color-scheme: dark) { | ||
| 26 | + :root { | ||
| 27 | + --bg: #11181b; | ||
| 28 | + --surface: #182226; | ||
| 29 | + --ink: #eef2f0; | ||
| 30 | + --muted: #a3b3b6; | ||
| 31 | + --line: #2b393e; | ||
| 32 | + --visited: #46c6b7; | ||
| 33 | + --visited-soft: #17332f; | ||
| 34 | + --wish: #f5a06c; | ||
| 35 | + --wish-soft: #3a2619; | ||
| 36 | + --danger: #ff8f84; | ||
| 37 | + --on-accent: #0b1416; | ||
| 38 | + --shadow: 0 1px 2px rgb(0 0 0 / 0.4); | ||
| 39 | + } | ||
| 40 | +} | ||
| 41 | + | ||
| 42 | +*, | ||
| 43 | +*::before, | ||
| 44 | +*::after { | ||
| 45 | + box-sizing: border-box; | ||
| 46 | +} | ||
| 47 | + | ||
| 48 | +body { | ||
| 49 | + margin: 0; | ||
| 50 | + background: var(--bg); | ||
| 51 | + color: var(--ink); | ||
| 52 | + font-family: var(--font-body); | ||
| 53 | + line-height: 1.5; | ||
| 54 | +} | ||
| 55 | + | ||
| 56 | +h1, | ||
| 57 | +h2 { | ||
| 58 | + margin: 0; | ||
| 59 | + font-family: var(--font-display); | ||
| 60 | + line-height: 1.2; | ||
| 61 | +} | ||
| 62 | + | ||
| 63 | +h1 { | ||
| 64 | + font-size: clamp(1.7rem, 4vw, 2.4rem); | ||
| 65 | +} | ||
| 66 | + | ||
| 67 | +h2 { | ||
| 68 | + font-size: 1.25rem; | ||
| 69 | +} | ||
| 70 | + | ||
| 71 | +a { | ||
| 72 | + color: var(--visited); | ||
| 73 | +} | ||
| 74 | + | ||
| 75 | +:focus-visible { | ||
| 76 | + outline: 3px solid color-mix(in srgb, var(--visited) 60%, transparent); | ||
| 77 | + outline-offset: 2px; | ||
| 78 | +} | ||
| 79 | + | ||
| 80 | +/* ---------- Mise en page ---------- */ | ||
| 81 | + | ||
| 82 | +.page { | ||
| 83 | + max-width: 76rem; | ||
| 84 | + margin: 0 auto; | ||
| 85 | + padding: 1.75rem var(--gutter) 1rem; | ||
| 86 | +} | ||
| 87 | + | ||
| 88 | +.page__header { | ||
| 89 | + margin-bottom: 1.5rem; | ||
| 90 | +} | ||
| 91 | + | ||
| 92 | +.eyebrow { | ||
| 93 | + margin: 0 0 0.25rem; | ||
| 94 | + color: var(--wish); | ||
| 95 | + font-size: 0.8rem; | ||
| 96 | + font-weight: 700; | ||
| 97 | + letter-spacing: 0.08em; | ||
| 98 | + text-transform: uppercase; | ||
| 99 | +} | ||
| 100 | + | ||
| 101 | +.layout { | ||
| 102 | + display: grid; | ||
| 103 | + gap: 1.25rem; | ||
| 104 | + grid-template-columns: minmax(0, 1fr); | ||
| 105 | + align-items: start; | ||
| 106 | +} | ||
| 107 | + | ||
| 108 | +@media (min-width: 62rem) { | ||
| 109 | + .layout { | ||
| 110 | + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); | ||
| 111 | + } | ||
| 112 | + | ||
| 113 | + .layout--map-first { | ||
| 114 | + grid-template-columns: minmax(0, 7fr) minmax(0, 5fr); | ||
| 115 | + } | ||
| 116 | + | ||
| 117 | + .sticky { | ||
| 118 | + position: sticky; | ||
| 119 | + top: 5rem; | ||
| 120 | + } | ||
| 121 | +} | ||
| 122 | + | ||
| 123 | +.map-slot { | ||
| 124 | + height: clamp(320px, 55vh, 560px); | ||
| 125 | + margin-top: 0.75rem; | ||
| 126 | +} | ||
| 127 | + | ||
| 128 | +/* ---------- Cartes et statistiques ---------- */ | ||
| 129 | + | ||
| 130 | +.card { | ||
| 131 | + padding: 1.25rem; | ||
| 132 | + border: 1px solid var(--line); | ||
| 133 | + border-radius: var(--radius); | ||
| 134 | + background: var(--surface); | ||
| 135 | + box-shadow: var(--shadow); | ||
| 136 | +} | ||
| 137 | + | ||
| 138 | +.card__head { | ||
| 139 | + display: flex; | ||
| 140 | + flex-wrap: wrap; | ||
| 141 | + align-items: baseline; | ||
| 142 | + justify-content: space-between; | ||
| 143 | + gap: 0.5rem; | ||
| 144 | +} | ||
| 145 | + | ||
| 146 | +.stat-grid { | ||
| 147 | + display: grid; | ||
| 148 | + gap: 1rem; | ||
| 149 | + grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr)); | ||
| 150 | + margin: 0 0 1.25rem; | ||
| 151 | + padding: 0; | ||
| 152 | + list-style: none; | ||
| 153 | +} | ||
| 154 | + | ||
| 155 | +.stat { | ||
| 156 | + display: flex; | ||
| 157 | + flex-direction: column; | ||
| 158 | +} | ||
| 159 | + | ||
| 160 | +.stat__value { | ||
| 161 | + color: var(--visited); | ||
| 162 | + font-family: var(--font-display); | ||
| 163 | + font-size: 2.4rem; | ||
| 164 | + font-weight: 700; | ||
| 165 | + line-height: 1; | ||
| 166 | +} | ||
| 167 | + | ||
| 168 | +.stat__label, | ||
| 169 | +.muted, | ||
| 170 | +.hint { | ||
| 171 | + color: var(--muted); | ||
| 172 | +} | ||
| 173 | + | ||
| 174 | +.hint { | ||
| 175 | + margin: -0.25rem 0 0; | ||
| 176 | + font-size: 0.85rem; | ||
| 177 | +} | ||
| 178 | + | ||
| 179 | +.legend { | ||
| 180 | + display: flex; | ||
| 181 | + gap: 1rem; | ||
| 182 | + margin: 0; | ||
| 183 | + padding: 0; | ||
| 184 | + color: var(--muted); | ||
| 185 | + font-size: 0.85rem; | ||
| 186 | + list-style: none; | ||
| 187 | +} | ||
| 188 | + | ||
| 189 | +.legend__dot { | ||
| 190 | + display: inline-block; | ||
| 191 | + width: 0.8rem; | ||
| 192 | + height: 0.8rem; | ||
| 193 | + margin-right: 0.35rem; | ||
| 194 | + border-radius: 50%; | ||
| 195 | + vertical-align: -0.1rem; | ||
| 196 | +} | ||
| 197 | + | ||
| 198 | +.legend__dot--visited { | ||
| 199 | + background: var(--visited); | ||
| 200 | +} | ||
| 201 | + | ||
| 202 | +.legend__dot--wishlist { | ||
| 203 | + border: 2px dashed var(--wish); | ||
| 204 | +} | ||
| 205 | + | ||
| 206 | +.empty, | ||
| 207 | +.notice { | ||
| 208 | + margin: 1rem 0; | ||
| 209 | +} | ||
| 210 | + | ||
| 211 | +.notice:empty { | ||
| 212 | + display: none; | ||
| 213 | +} | ||
| 214 | + | ||
| 215 | +/* ---------- Listes ---------- */ | ||
| 216 | + | ||
| 217 | +.timeline, | ||
| 218 | +.wish-list { | ||
| 219 | + margin: 1rem 0 0; | ||
| 220 | + padding: 0; | ||
| 221 | + list-style: none; | ||
| 222 | +} | ||
| 223 | + | ||
| 224 | +.timeline__item, | ||
| 225 | +.wish { | ||
| 226 | + display: grid; | ||
| 227 | + gap: 0.2rem; | ||
| 228 | + padding: 0.85rem 0; | ||
| 229 | + border-top: 1px solid var(--line); | ||
| 230 | +} | ||
| 231 | + | ||
| 232 | +.timeline__main, | ||
| 233 | +.timeline__meta, | ||
| 234 | +.wish__head { | ||
| 235 | + display: flex; | ||
| 236 | + flex-wrap: wrap; | ||
| 237 | + align-items: baseline; | ||
| 238 | + justify-content: space-between; | ||
| 239 | + gap: 0.25rem 0.75rem; | ||
| 240 | +} | ||
| 241 | + | ||
| 242 | +.timeline__meta { | ||
| 243 | + justify-content: flex-start; | ||
| 244 | + color: var(--muted); | ||
| 245 | + font-size: 0.9rem; | ||
| 246 | +} | ||
| 247 | + | ||
| 248 | +.timeline__note, | ||
| 249 | +.wish__note { | ||
| 250 | + margin: 0.2rem 0 0; | ||
| 251 | +} | ||
| 252 | + | ||
| 253 | +.badge { | ||
| 254 | + padding: 0.1rem 0.6rem; | ||
| 255 | + border-radius: 999px; | ||
| 256 | + background: var(--visited-soft); | ||
| 257 | + color: var(--visited); | ||
| 258 | + font-size: 0.78rem; | ||
| 259 | + font-weight: 700; | ||
| 260 | +} | ||
| 261 | + | ||
| 262 | +.badge--high { | ||
| 263 | + background: var(--wish-soft); | ||
| 264 | + color: var(--wish); | ||
| 265 | +} | ||
| 266 | + | ||
| 267 | +/* ---------- Boutons ---------- */ | ||
| 268 | + | ||
| 269 | +.btn { | ||
| 270 | + display: inline-flex; | ||
| 271 | + align-items: center; | ||
| 272 | + justify-content: center; | ||
| 273 | + padding: 0.65rem 1.1rem; | ||
| 274 | + border: 1px solid var(--line); | ||
| 275 | + border-radius: 10px; | ||
| 276 | + background: var(--surface); | ||
| 277 | + color: var(--ink); | ||
| 278 | + font: inherit; | ||
| 279 | + font-weight: 600; | ||
| 280 | + text-decoration: none; | ||
| 281 | + cursor: pointer; | ||
| 282 | +} | ||
| 283 | + | ||
| 284 | +.btn:hover { | ||
| 285 | + border-color: var(--visited); | ||
| 286 | +} | ||
| 287 | + | ||
| 288 | +.btn--primary { | ||
| 289 | + border-color: var(--visited); | ||
| 290 | + background: var(--visited); | ||
| 291 | + color: var(--on-accent); | ||
| 292 | +} | ||
| 293 | + | ||
| 294 | +.btn--danger { | ||
| 295 | + border-color: var(--danger); | ||
| 296 | + color: var(--danger); | ||
| 297 | +} | ||
| 298 | + | ||
| 299 | +.btn--sm { | ||
| 300 | + padding: 0.4rem 0.8rem; | ||
| 301 | + font-size: 0.88rem; | ||
| 302 | +} | ||
| 303 | + | ||
| 304 | +.actions { | ||
| 305 | + display: flex; | ||
| 306 | + flex-wrap: wrap; | ||
| 307 | + gap: 0.5rem; | ||
| 308 | + margin-top: 0.6rem; | ||
| 309 | +} | ||
| 310 | + | ||
| 311 | +/* ---------- Formulaires ---------- */ | ||
| 312 | + | ||
| 313 | +.form { | ||
| 314 | + display: grid; | ||
| 315 | + gap: 1.1rem; | ||
| 316 | +} | ||
| 317 | + | ||
| 318 | +.row { | ||
| 319 | + display: grid; | ||
| 320 | + gap: 1rem; | ||
| 321 | + grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); | ||
| 322 | +} | ||
| 323 | + | ||
| 324 | +.field { | ||
| 325 | + display: grid; | ||
| 326 | + gap: 0.35rem; | ||
| 327 | + align-content: start; | ||
| 328 | +} | ||
| 329 | + | ||
| 330 | +.field label, | ||
| 331 | +.segmented legend { | ||
| 332 | + font-size: 0.9rem; | ||
| 333 | + font-weight: 600; | ||
| 334 | +} | ||
| 335 | + | ||
| 336 | +input[type='text'], | ||
| 337 | +input[type='number'], | ||
| 338 | +input[type='date'], | ||
| 339 | +select, | ||
| 340 | +textarea { | ||
| 341 | + width: 100%; | ||
| 342 | + padding: 0.65rem 0.8rem; | ||
| 343 | + border: 1px solid var(--line); | ||
| 344 | + border-radius: 10px; | ||
| 345 | + background: var(--bg); | ||
| 346 | + color: var(--ink); | ||
| 347 | + font: inherit; | ||
| 348 | +} | ||
| 349 | + | ||
| 350 | +input.ng-invalid.ng-touched, | ||
| 351 | +select.ng-invalid.ng-touched, | ||
| 352 | +textarea.ng-invalid.ng-touched { | ||
| 353 | + border-color: var(--danger); | ||
| 354 | +} | ||
| 355 | + | ||
| 356 | +.field__error { | ||
| 357 | + margin: 0; | ||
| 358 | + color: var(--danger); | ||
| 359 | + font-size: 0.85rem; | ||
| 360 | +} | ||
| 361 | + | ||
| 362 | +.segmented { | ||
| 363 | + display: flex; | ||
| 364 | + flex-wrap: wrap; | ||
| 365 | + gap: 0.5rem; | ||
| 366 | + margin: 0; | ||
| 367 | + padding: 0; | ||
| 368 | + border: 0; | ||
| 369 | +} | ||
| 370 | + | ||
| 371 | +.segmented legend { | ||
| 372 | + width: 100%; | ||
| 373 | + margin-bottom: 0.35rem; | ||
| 374 | + padding: 0; | ||
| 375 | +} | ||
| 376 | + | ||
| 377 | +.segmented label { | ||
| 378 | + display: inline-flex; | ||
| 379 | + align-items: center; | ||
| 380 | + gap: 0.5rem; | ||
| 381 | + padding: 0.55rem 0.9rem; | ||
| 382 | + border: 1px solid var(--line); | ||
| 383 | + border-radius: 999px; | ||
| 384 | + cursor: pointer; | ||
| 385 | +} | ||
| 386 | + | ||
| 387 | +.segmented label:has(input:checked) { | ||
| 388 | + border-color: var(--visited); | ||
| 389 | + background: var(--visited-soft); | ||
| 390 | +} | ||