yatho/demo-angular-migrationpublic Fork
⑂ cbf84e0
Commits
⬇ Clone ▾
git clone https://git.rickub.com/yatho/demo-angular-migration.git
git clone ssh://git@rickub.com/yatho/demo-angular-migration.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Initial commitUnverified

Yann-thomas Le Moigne committed 2026-10-10T17:55:41+02:00 Browse files
cbf84e0 parent: b426aed
modified angular.json +4 -1
@@ -32,7 +32,10 @@
3232 "styles": [
3333 "src/styles.css"
3434 ],
35- "scripts": []
35+ "scripts": [],
36+ "allowedCommonJsDependencies": [
37+ "leaflet"
38+ ]
3639 },
3740 "configurations": {
3841 "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 @@
1818 "@angular/platform-browser": "^18.2.0",
1919 "@angular/platform-browser-dynamic": "^18.2.0",
2020 "@angular/router": "^18.2.0",
21+ "leaflet": "^1.9.4",
2122 "rxjs": "~7.8.0",
2223 "tslib": "^2.3.0",
2324 "zone.js": "~0.14.10"
@@ -27,6 +28,7 @@
2728 "@angular/cli": "^18.2.21",
2829 "@angular/compiler-cli": "^18.2.0",
2930 "@types/jasmine": "~5.1.0",
31+ "@types/leaflet": "^1.9.12",
3032 "jasmine-core": "~5.2.0",
3133 "karma": "~6.4.0",
3234 "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:
3232 '@angular/router':
3333 specifier: ^18.2.0
3434 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
3538 rxjs:
3639 specifier: ~7.8.0
3740 version: 7.8.2
@@ -54,6 +57,9 @@ importers:
5457 '@types/jasmine':
5558 specifier: ~5.1.0
5659 version: 5.1.15
60+ '@types/leaflet':
61+ specifier: ^1.9.12
62+ version: 1.9.22
5763 jasmine-core:
5864 specifier: ~5.2.0
5965 version: 5.2.0
@@ -1559,6 +1565,9 @@ packages:
15591565 '@types/express@4.17.25':
15601566 resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==}
15611567
1568+ '@types/geojson@7946.0.16':
1569+ resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
1570+
15621571 '@types/http-errors@2.0.5':
15631572 resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
15641573
@@ -1571,6 +1580,9 @@ packages:
15711580 '@types/json-schema@7.0.15':
15721581 resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
15731582
1583+ '@types/leaflet@1.9.22':
1584+ resolution: {integrity: sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==}
1585+
15741586 '@types/mime@1.3.5':
15751587 resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
15761588
@@ -2833,6 +2845,9 @@ packages:
28332845 launch-editor@2.14.2:
28342846 resolution: {integrity: sha512-NHJEb30MGBCkmZXLZwz25t37QR4aY03ZzPYUJXOJaOa92uWwExI9SBQUuIznBYc1xkD7cJNGX7oc3Nl3sq2kQQ==}
28352847
2848+ leaflet@1.9.4:
2849+ resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==}
2850+
28362851 less-loader@12.2.0:
28372852 resolution: {integrity: sha512-MYUxjSQSBUQmowc0l5nPieOYwMzGPUaTzB6inNW/bdPEG9zOL3eAAD1Qw5ZxSPk7we5dMojHwNODYMV1hq4EVg==}
28382853 engines: {node: '>= 18.12.0'}
@@ -4243,7 +4258,7 @@ snapshots:
42434258 dependencies:
42444259 '@ampproject/remapping': 2.3.0
42454260 '@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))
42474262 '@angular-devkit/core': 18.2.21(chokidar@3.6.0)
42484263 '@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)
42494264 '@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:
42994314 typescript: 5.5.4
43004315 watchpack: 2.4.1
43014316 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))
43044319 webpack-merge: 6.0.1
43054320 webpack-subresource-integrity: 5.1.0(webpack@5.94.0(esbuild@0.23.0)(postcss@8.4.41))
43064321 optionalDependencies:
@@ -4331,12 +4346,12 @@ snapshots:
43314346 - utf-8-validate
43324347 - webpack-cli
43334348
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))':
43354350 dependencies:
43364351 '@angular-devkit/architect': 0.1802.21(chokidar@3.6.0)
43374352 rxjs: 7.8.1
43384353 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))
43404355 transitivePeerDependencies:
43414356 - chokidar
43424357
@@ -5920,6 +5935,8 @@ snapshots:
59205935 '@types/qs': 6.15.1
59215936 '@types/serve-static': 1.15.10
59225937
5938+ '@types/geojson@7946.0.16': {}
5939+
59235940 '@types/http-errors@2.0.5': {}
59245941
59255942 '@types/http-proxy@1.17.17':
@@ -5930,6 +5947,10 @@ snapshots:
59305947
59315948 '@types/json-schema@7.0.15': {}
59325949
5950+ '@types/leaflet@1.9.22':
5951+ dependencies:
5952+ '@types/geojson': 7946.0.16
5953+
59335954 '@types/mime@1.3.5': {}
59345955
59355956 '@types/mute-stream@0.0.4':
@@ -7349,6 +7370,8 @@ snapshots:
73497370 picocolors: 1.1.1
73507371 shell-quote: 1.12.0
73517372
7373+ leaflet@1.9.4: {}
7374+
73527375 less-loader@12.2.0(less@4.2.0)(webpack@5.94.0(esbuild@0.23.0)(postcss@8.4.41)):
73537376 dependencies:
73547377 less: 4.2.0
@@ -8466,7 +8489,7 @@ snapshots:
84668489 mkdirp: 1.0.4
84678490 yallist: 4.0.0
84688491
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)):
84708493 dependencies:
84718494 '@jridgewell/trace-mapping': 0.3.31
84728495 jest-worker: 27.5.1
@@ -8616,7 +8639,7 @@ snapshots:
86168639
86178640 weak-lru-cache@1.2.2: {}
86188641
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)):
86208643 dependencies:
86218644 colorette: 2.0.20
86228645 memfs: 4.80.0
@@ -8627,7 +8650,7 @@ snapshots:
86278650 optionalDependencies:
86288651 webpack: 5.94.0(esbuild@0.23.0)(postcss@8.4.41)
86298652
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)):
86318654 dependencies:
86328655 '@types/bonjour': 3.5.13
86338656 '@types/connect-history-api-fallback': 1.5.4
@@ -8655,7 +8678,7 @@ snapshots:
86558678 serve-index: 1.9.2
86568679 sockjs: 0.3.24
86578680 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))
86598682 ws: 8.22.0
86608683 optionalDependencies:
86618684 webpack: 5.94.0(esbuild@0.23.0)(postcss@8.4.41)
@@ -8700,7 +8723,7 @@ snapshots:
87008723 neo-async: 2.6.2
87018724 schema-utils: 3.3.0
87028725 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))
87048727 watchpack: 2.4.1
87058728 webpack-sources: 3.6.0
87068729 transitivePeerDependencies:
@@ -32,6 +32,9 @@ importers:
32 '@angular/router':32 '@angular/router':
33 specifier: ^18.2.033 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.039 specifier: ~7.8.0
37 version: 7.8.240 version: 7.8.2
@@ -54,6 +57,9 @@ importers:
54 '@types/jasmine':57 '@types/jasmine':
55 specifier: ~5.1.058 specifier: ~5.1.0
56 version: 5.1.1559 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.064 specifier: ~5.2.0
59 version: 5.2.065 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.04259 '@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.44314 typescript: 5.5.4
4300 watchpack: 2.4.14315 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.14319 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-validate4346 - utf-8-validate
4332 - webpack-cli4347 - 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.14352 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 - chokidar4356 - chokidar
4342 4357
@@ -5920,6 +5935,8 @@ snapshots:
5920 '@types/qs': 6.15.15935 '@types/qs': 6.15.1
5921 '@types/serve-static': 1.15.105936 '@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.17370 picocolors: 1.1.1
7350 shell-quote: 1.12.07371 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.07377 less: 4.2.0
@@ -8466,7 +8489,7 @@ snapshots:
8466 mkdirp: 1.0.48489 mkdirp: 1.0.4
8467 yallist: 4.0.08490 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.318494 '@jridgewell/trace-mapping': 0.3.31
8472 jest-worker: 27.5.18495 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.208644 colorette: 2.0.20
8622 memfs: 4.80.08645 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.138655 '@types/bonjour': 3.5.13
8633 '@types/connect-history-api-fallback': 1.5.48656 '@types/connect-history-api-fallback': 1.5.4
@@ -8655,7 +8678,7 @@ snapshots:
8655 serve-index: 1.9.28678 serve-index: 1.9.2
8656 sockjs: 0.3.248679 sockjs: 0.3.24
8657 spdy: 4.0.28680 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.08682 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.28723 neo-async: 2.6.2
8701 schema-utils: 3.3.08724 schema-utils: 3.3.0
8702 tapable: 2.3.38725 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.18727 watchpack: 2.4.1
8705 webpack-sources: 3.6.08728 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>
32522 </main>
32623
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 @@
11 import { TestBed } from '@angular/core/testing';
2+import { provideRouter } from '@angular/router';
23 import { AppComponent } from './app.component';
4+import { memoryStorage } from './travel/testing/memory-storage';
5+import { TRAVEL_STORAGE } from './travel/services/travel-store.service';
36
47 describe('AppComponent', () => {
58 beforeEach(async () => {
69 await TestBed.configureTestingModule({
710 imports: [AppComponent],
11+ providers: [provideRouter([]), { provide: TRAVEL_STORAGE, useValue: memoryStorage() }],
812 }).compileComponents();
913 });
1014
1115 it('should create the app', () => {
1216 const fixture = TestBed.createComponent(AppComponent);
13- const app = fixture.componentInstance;
14- expect(app).toBeTruthy();
17+ expect(fixture.componentInstance).toBeTruthy();
1518 });
1619
17- it(`should have the 'roadTrip' title`, () => {
20+ it(`should have the 'RoadTrip' title`, () => {
1821 const fixture = TestBed.createComponent(AppComponent);
19- const app = fixture.componentInstance;
20- expect(app.title).toEqual('roadTrip');
22+ expect(fixture.componentInstance.title).toEqual('RoadTrip');
2123 });
2224
23- it('should render title', () => {
25+ it('affiche la marque et la navigation principale', () => {
2426 const fixture = TestBed.createComponent(AppComponent);
2527 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');
2840 });
2941 });
@@ -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 @@
11 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';
34
45 @Component({
56 selector: 'app-root',
67 standalone: true,
7- imports: [RouterOutlet],
8+ imports: [RouterOutlet, RouterLink, RouterLinkActive],
89 templateUrl: './app.component.html',
9- styleUrl: './app.component.css'
10+ styleUrl: './app.component.css',
1011 })
1112 export class AppComponent {
12- title = 'roadTrip';
13+ title = 'RoadTrip';
14+
15+ constructor(public store: TravelStore) {}
1316 }
@@ -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';
24 import { provideRouter } from '@angular/router';
35
46 import { routes } from './app.routes';
57
8+registerLocaleData(localeFr);
9+
610 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+ ],
816 };
@@ -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 @@
11 import { Routes } from '@angular/router';
22
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: '&copy; <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: '&copy; <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 @@
11 <!doctype html>
2-<html lang="en">
2+<html lang="fr">
33 <head>
44 <meta charset="utf-8">
55 <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+}