diff --git a/front-end/angular.json b/front-end/angular.json index c384268..ae6c443 100644 --- a/front-end/angular.json +++ b/front-end/angular.json @@ -15,12 +15,14 @@ "prefix": "app", "architect": { "build": { - "builder": "@angular-devkit/build-angular:application", + "builder": "@ngx-env/builder:application", "options": { "outputPath": "dist/regard-d-altitude", "index": "src/index.html", "browser": "src/main.ts", - "polyfills": ["zone.js"], + "polyfills": [ + "zone.js" + ], "tsConfig": "tsconfig.app.json", "inlineStyleLanguage": "scss", "assets": [ @@ -79,7 +81,7 @@ "defaultConfiguration": "production" }, "serve": { - "builder": "@angular-devkit/build-angular:dev-server", + "builder": "@ngx-env/builder:dev-server", "configurations": { "production": { "buildTarget": "regard-d-altitude:build:production" @@ -91,12 +93,15 @@ "defaultConfiguration": "development" }, "extract-i18n": { - "builder": "@angular-devkit/build-angular:extract-i18n" + "builder": "@ngx-env/builder:extract-i18n" }, "test": { - "builder": "@angular-devkit/build-angular:karma", + "builder": "@ngx-env/builder:karma", "options": { - "polyfills": ["zone.js", "zone.js/testing"], + "polyfills": [ + "zone.js", + "zone.js/testing" + ], "tsConfig": "tsconfig.spec.json", "inlineStyleLanguage": "scss", "assets": [ @@ -122,4 +127,4 @@ } } } -} +} \ No newline at end of file diff --git a/front-end/package-lock.json b/front-end/package-lock.json index 1925a86..61a9a4f 100644 --- a/front-end/package-lock.json +++ b/front-end/package-lock.json @@ -39,6 +39,7 @@ "@angular-devkit/build-angular": "^18.0.6", "@angular/cli": "^18.0.6", "@angular/compiler-cli": "^18.0.5", + "@ngx-env/builder": "^18.0.1", "@types/express": "^4.17.17", "@types/jasmine": "~5.1.0", "@types/leaflet": "^1.9.12", @@ -2652,6 +2653,194 @@ "node": ">=10.0.0" } }, + "node_modules/@dotenv-run/core": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@dotenv-run/core/-/core-1.3.5.tgz", + "integrity": "sha512-jwXSX/r4VIGUOd/xqRjD5QUYRhKhKOmhhFSPDaIOsajFL+LVYBGADTFcakZ5O9x2BOGxDjIVUJzIE8jTpJ29JA==", + "dev": true, + "dependencies": { + "chalk": "^4.1.0", + "dotenv": "^16.1.4", + "dotenv-expand": "^10.0.0", + "find-up": "^5.0.0" + } + }, + "node_modules/@dotenv-run/core/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@dotenv-run/core/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@dotenv-run/core/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@dotenv-run/core/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/@dotenv-run/core/node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@dotenv-run/core/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@dotenv-run/core/node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@dotenv-run/core/node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@dotenv-run/core/node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@dotenv-run/core/node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@dotenv-run/core/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@dotenv-run/core/node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@dotenv-run/esbuild": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@dotenv-run/esbuild/-/esbuild-1.4.0.tgz", + "integrity": "sha512-pXT4qARGRtWtL+/8MVGNc+oatVeNjK5Y415IjxMnGfriMwYuAwz5cWHB4YHIUTpPjkVCxsnPVb4wwr888IuN3g==", + "dev": true, + "dependencies": { + "@dotenv-run/core": "~1.3.5" + }, + "peerDependencies": { + "esbuild": "0.21.3" + } + }, + "node_modules/@dotenv-run/webpack": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@dotenv-run/webpack/-/webpack-1.4.0.tgz", + "integrity": "sha512-gQKOdSWs4iYaJ628A3YpPbM0XJvdCg44Wck1kL+UuumRboGcPoq5M35KKmrBQ/abjGeawrZyYimD4T1PiZ9bCA==", + "dev": true, + "dependencies": { + "@dotenv-run/core": "^1.3.4" + }, + "peerDependencies": { + "webpack": "^5.0.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.3.tgz", @@ -4188,6 +4377,61 @@ "webpack": "^5.54.0" } }, + "node_modules/@ngx-env/builder": { + "version": "18.0.1", + "resolved": "https://registry.npmjs.org/@ngx-env/builder/-/builder-18.0.1.tgz", + "integrity": "sha512-zADxSVnhbRqRSWJI09481JNFdsApAwPynDY1UeHWp5BGFI7Qur1fyqjV8ZMqAHhzOv35Z0Wb4JEm8d1/ukQTIg==", + "dev": true, + "dependencies": { + "@dotenv-run/esbuild": "^1.4.0", + "@dotenv-run/webpack": "^1.4.0", + "glob": "^10.3.10" + } + }, + "node_modules/@ngx-env/builder/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@ngx-env/builder/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@ngx-env/builder/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -6988,6 +7232,27 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/dotenv": { + "version": "16.4.5", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", + "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-10.0.0.tgz", + "integrity": "sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==", + "dev": true, + "engines": { + "node": ">=12" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", diff --git a/front-end/package.json b/front-end/package.json index 62b9b0f..b6b9569 100644 --- a/front-end/package.json +++ b/front-end/package.json @@ -45,6 +45,7 @@ "@angular-devkit/build-angular": "^18.0.6", "@angular/cli": "^18.0.6", "@angular/compiler-cli": "^18.0.5", + "@ngx-env/builder": "^18.0.1", "@types/express": "^4.17.17", "@types/jasmine": "~5.1.0", "@types/leaflet": "^1.9.12", diff --git a/front-end/src/app/app.component.html b/front-end/src/app/app.component.html index a0d952e..51b4115 100644 --- a/front-end/src/app/app.component.html +++ b/front-end/src/app/app.component.html @@ -7,7 +7,11 @@ @@ -18,7 +22,10 @@ {{ title }} - + diff --git a/front-end/src/app/app.component.scss b/front-end/src/app/app.component.scss index 69f5198..299aec2 100644 --- a/front-end/src/app/app.component.scss +++ b/front-end/src/app/app.component.scss @@ -1,14 +1,30 @@ -main { - height: calc(100vh - var(--mat-toolbar-standard-height)); +mat-sidenav-container { + height: calc( + 100vh - var(--mat-toolbar-standard-height) - var( + --mat-toolbar-standard-height + ) + ); background: var(--mat-toolbar-container-background-color); } +.mat-sidenav-container-home { + height: calc(100vh - var(--mat-toolbar-standard-height)); +} + @media (max-width: 599px) { - main { - height: calc(100vh - var(--mat-toolbar-mobile-height)); + mat-sidenav-container { + height: calc( + 100vh - var(--mat-toolbar-mobile-height) - var( + --mat-toolbar-mobile-height + ) + ); } } +main { + height: 100%; +} + .toolbar-spacer { flex: 1 1 auto; } diff --git a/front-end/src/app/app.component.ts b/front-end/src/app/app.component.ts index 3aa8ccc..d774e3b 100644 --- a/front-end/src/app/app.component.ts +++ b/front-end/src/app/app.component.ts @@ -18,11 +18,13 @@ import { import { MatListModule } from '@angular/material/list'; import { MatSidenav, MatSidenavModule } from '@angular/material/sidenav'; import { Location } from '@angular/common'; -import { AuthService } from './services/auth.service'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { Platform } from '@angular/cdk/platform'; import { MatBadgeModule } from '@angular/material/badge'; import { MatDividerModule } from '@angular/material/divider'; import { OfflineService } from './services/offline.service'; +import { AuthService } from './services/auth.service'; +import { SettingsService } from './services/settings.service'; @Component({ selector: 'app-root', @@ -38,6 +40,7 @@ import { OfflineService } from './services/offline.service'; MatListModule, MatBadgeModule, MatDividerModule, + MatSnackBarModule, ], templateUrl: './app.component.html', styleUrl: './app.component.scss', @@ -51,7 +54,10 @@ export class AppComponent { router = inject(Router); location = inject(Location); authService = inject(AuthService); + settingsService = inject(SettingsService); + offlineService = inject(OfflineService); + snackBar = inject(MatSnackBar); @ViewChild('sidenav') private sidenav!: MatSidenav; @@ -75,9 +81,16 @@ export class AppComponent { { id: 3, text: 'Saisir une nouvelle observation', - routerLink: 'nouvelle-observation', - authenficated: true, - click: () => null, + routerLink: null, + authenficated: null, + click: () => { + this.sidenav.close(); + this.router.navigate([ + this.authService.isAuth.value + ? '/nouvelle-observation' + : '/se-connecter', + ]); + }, observationsPending: false, }, { @@ -98,20 +111,38 @@ export class AppComponent { }, { id: 6, - text: 'Mes données hors ligne', - routerLink: 'mes-donnees-hors-ligne', + text: 'Fonds de carte hors ligne', + routerLink: 'fonds-de-carte-hors-ligne', authenficated: true, click: () => null, observationsPending: false, }, { id: 7, + text: 'En savoir plus', + routerLink: 'en-savoir-plus', + authenficated: null, + click: () => null, + observationsPending: false, + }, + { + id: 8, + text: 'Mentions légales', + routerLink: 'legal-notice', + authenficated: null, + click: () => null, + observationsPending: false, + }, + { + id: 0, text: 'Me déconnecter', routerLink: null, authenficated: true, click: () => { this.authService.logout(); this.sidenav.close(); + this.snackBar.open('Vous êtes déconnecté', '', { duration: 2000 }); + this.router.navigate(['..']); }, observationsPending: false, }, @@ -138,14 +169,22 @@ export class AppComponent { this.authService.checkAuth(); } afterNextRender(() => { - this.offlineService.handleObservationsPending(); + if (this.authService.isAuth) { + this.offlineService.handleObservationsPending(); + } }); } ngOnInit() { this.authService.isAuth.subscribe((value) => { + if (value) { + this.authService.getAccount().subscribe((account: any) => { + this.authService.setUser(account); + }); + } this.handleAuthentification(value); }); + this.router.events.subscribe((event) => { if (event instanceof ActivationEnd) { if (this.sidenav.opened) { diff --git a/front-end/src/app/app.config.ts b/front-end/src/app/app.config.ts index 82042cc..259927f 100644 --- a/front-end/src/app/app.config.ts +++ b/front-end/src/app/app.config.ts @@ -2,6 +2,7 @@ import { ApplicationConfig, provideZoneChangeDetection, isDevMode, + APP_INITIALIZER, } from '@angular/core'; import { provideRouter, withComponentInputBinding } from '@angular/router'; @@ -10,9 +11,18 @@ import { provideClientHydration } from '@angular/platform-browser'; import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; import { MAT_DATE_LOCALE } from '@angular/material/core'; import { provideMomentDateAdapter } from '@angular/material-moment-adapter'; -import { provideHttpClient, withFetch } from '@angular/common/http'; +import { + provideHttpClient, + withFetch, + withInterceptorsFromDi, +} from '@angular/common/http'; import { provideServiceWorker } from '@angular/service-worker'; +import { httpInterceptorProviders } from './interceptors/http.interceptor'; +import { SettingsService } from './services/settings.service'; +export function tokenGetter() { + return localStorage.getItem('access_token'); +} export const appConfig: ApplicationConfig = { providers: [ provideZoneChangeDetection({ eventCoalescing: true }), @@ -31,10 +41,30 @@ export const appConfig: ApplicationConfig = { monthYearA11yLabel: 'MMMM YYYY', }, }), - provideHttpClient(withFetch()), + httpInterceptorProviders, + provideHttpClient(withInterceptorsFromDi(), withFetch()), provideServiceWorker('ngsw-worker.js', { enabled: !isDevMode(), registrationStrategy: 'registerWhenStable:30000', }), + { + provide: APP_INITIALIZER, + useFactory: (settingsService: SettingsService) => async () => { + return new Promise(async (resolve) => { + settingsService.getSettings().subscribe({ + next: async (settings: any) => { + await settingsService.setSettings(settings); + resolve(true); + }, + error: async () => { + await settingsService.useOfflineSettings(); + resolve(true); + }, + }); + }); + }, + deps: [SettingsService], + multi: true, + }, ], }; diff --git a/front-end/src/app/app.routes.ts b/front-end/src/app/app.routes.ts index 1ffee9f..a6e787e 100644 --- a/front-end/src/app/app.routes.ts +++ b/front-end/src/app/app.routes.ts @@ -11,6 +11,8 @@ import { ForgetPasswordComponent } from './pages/forget-password/forget-password import { ChangePasswordComponent } from './pages/change-password/change-password.component'; import { authGuard } from './guards/auth.guard'; import { ObservationDetailComponent } from './pages/observation-detail/observation-detail.component'; +import { LearnMoreComponent } from './pages/learn-more/learn-more.component'; +import { LegalNoticeComponent } from './pages/legal-notice/legal-notice.component'; export const routes: Routes = [ { @@ -91,7 +93,6 @@ export const routes: Routes = [ backButton: true, accountButton: false, }, - canActivate: [authGuard], }, { path: 'mon-compte', @@ -108,15 +109,35 @@ export const routes: Routes = [ canActivate: [authGuard], }, { - path: 'mes-donnees-hors-ligne', - title: 'Mes données hors ligne', + path: 'fonds-de-carte-hors-ligne', + title: 'Fonds de carte hors ligne', component: MyOfflineDataComponent, data: { - title: 'Mes données hors ligne', + title: 'Fonds de carte hors ligne', backButton: true, accountButton: false, }, canActivate: [authGuard], }, + { + path: 'en-savoir-plus', + title: 'En savoir plus', + component: LearnMoreComponent, + data: { + title: 'En savoir plus', + backButton: true, + accountButton: false, + }, + }, + { + path: 'legal-notice', + title: 'Mentions légales', + component: LegalNoticeComponent, + data: { + title: 'Mentions légales', + backButton: true, + accountButton: false, + }, + }, { path: '**', redirectTo: '' }, ]; diff --git a/front-end/src/app/interceptors/http.interceptor.ts b/front-end/src/app/interceptors/http.interceptor.ts new file mode 100644 index 0000000..591ce2e --- /dev/null +++ b/front-end/src/app/interceptors/http.interceptor.ts @@ -0,0 +1,95 @@ +import { inject, Injectable } from '@angular/core'; +import { + HttpEvent, + HttpInterceptor, + HttpHandler, + HttpRequest, + HTTP_INTERCEPTORS, + HttpErrorResponse, +} from '@angular/common/http'; +import { + BehaviorSubject, + catchError, + filter, + Observable, + switchMap, + take, + throwError, +} from 'rxjs'; +import { AuthService } from '../services/auth.service'; + +const TOKEN_HEADER_KEY = 'Authorization'; +@Injectable() +export class HttpRequestInterceptor implements HttpInterceptor { + isRefreshing = false; + refreshTokenSubject: BehaviorSubject = new BehaviorSubject(null); + + authService = inject(AuthService); + + intercept( + req: HttpRequest, + next: HttpHandler, + ): Observable> { + if (req.url.includes('account') && !req.url.includes('sign-up')) { + const token = this.authService.getToken(); + if (token) { + req = this.addTokenHeader(req, token); + } + } + + return next.handle(req).pipe( + catchError((error) => { + if ( + error instanceof HttpErrorResponse && + !req.url.includes('token') && + error.status === 401 + ) { + return this.handle401Error(req, next); + } + return throwError(() => error.error.detail); + }), + ); + } + + handle401Error(req: HttpRequest, next: HttpHandler) { + if (!this.isRefreshing) { + this.isRefreshing = true; + this.refreshTokenSubject.next(null); + + const refreshToken = this.authService.getRefreshToken(); + + if (refreshToken) { + return this.authService.refreshToken(refreshToken).pipe( + switchMap((token: any) => { + this.isRefreshing = false; + this.authService.saveToken(token.access); + this.refreshTokenSubject.next(token.access); + + return next.handle(this.addTokenHeader(req, token.access)); + }), + catchError(() => { + this.isRefreshing = false; + this.authService.logout(); + return throwError(() => new Error('Erreur')); + }), + ); + } + } + + return this.refreshTokenSubject.pipe( + filter((token) => token !== null), + take(1), + switchMap((token) => next.handle(this.addTokenHeader(req, token))), + ); + } + + addTokenHeader(req: HttpRequest, token: string) { + return req.clone({ + headers: req.headers.set(TOKEN_HEADER_KEY, `Bearer ${token}`), + }); + } +} + +export const httpInterceptorProviders = [ + { provide: HTTP_INTERCEPTORS, useClass: HttpRequestInterceptor, multi: true }, +]; diff --git a/front-end/src/app/pages/change-password/change-password.component.ts b/front-end/src/app/pages/change-password/change-password.component.ts index 92dd8e4..b82e6a1 100644 --- a/front-end/src/app/pages/change-password/change-password.component.ts +++ b/front-end/src/app/pages/change-password/change-password.component.ts @@ -11,6 +11,7 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { Router, RouterLink } from '@angular/router'; import { AuthService } from '../../services/auth.service'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; @Component({ selector: 'app-change-password', @@ -23,6 +24,7 @@ import { AuthService } from '../../services/auth.service'; ReactiveFormsModule, MatButtonModule, RouterLink, + MatSnackBarModule, ], templateUrl: './change-password.component.html', styleUrl: './change-password.component.scss', @@ -41,6 +43,7 @@ export class ChangePasswordComponent { router = inject(Router); authService = inject(AuthService); + snackBar = inject(MatSnackBar); ngOnInit() { this.passwordFormControl.valueChanges.subscribe((value) => { @@ -67,8 +70,21 @@ export class ChangePasswordComponent { this.passwordFormControl.valid && this.confirmPasswordFormControl.valid ) { - this.authService.logout(); - this.router.navigate(['/se-connecter']); + this.authService + .changePassword(this.passwordFormControl.value!) + .subscribe({ + next: (success) => { + console.log('success', success); + this.authService.logout(); + this.snackBar.open('Vous êtes déconnecté', '', { + duration: 2000, + }); + this.router.navigate(['/se-connecter']); + }, + error: (error) => { + console.log('error', error); + }, + }); } } else { this.passwordError = true; diff --git a/front-end/src/app/pages/create-account/create-account.component.html b/front-end/src/app/pages/create-account/create-account.component.html index 5630978..eb5340c 100644 --- a/front-end/src/app/pages/create-account/create-account.component.html +++ b/front-end/src/app/pages/create-account/create-account.component.html @@ -12,6 +12,20 @@ Une adresse email est nécessaire } + + Nom + + @if (lastNameFormControl.hasError("required")) { + Un nom nécessaire + } + + + Prénom + + @if (emailFormControl.hasError("required")) { + Un prénom nécessaire + } + Mot de passe diff --git a/front-end/src/app/pages/create-account/create-account.component.ts b/front-end/src/app/pages/create-account/create-account.component.ts index bed6af6..94a2d2f 100644 --- a/front-end/src/app/pages/create-account/create-account.component.ts +++ b/front-end/src/app/pages/create-account/create-account.component.ts @@ -10,6 +10,8 @@ import { MatButtonModule } from '@angular/material/button'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { Router, RouterLink } from '@angular/router'; +import { AuthService } from '../../services/auth.service'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; @Component({ selector: 'app-create-account', @@ -22,6 +24,7 @@ import { Router, RouterLink } from '@angular/router'; ReactiveFormsModule, MatButtonModule, RouterLink, + MatSnackBarModule, ], templateUrl: './create-account.component.html', styleUrl: './create-account.component.scss', @@ -31,6 +34,8 @@ export class CreateAccountComponent { Validators.required, Validators.email, ]); + firstNameFormControl = new FormControl('', [Validators.required]); + lastNameFormControl = new FormControl('', [Validators.required]); passwordFormControl = new FormControl('', [ Validators.required, Validators.minLength(6), @@ -43,6 +48,8 @@ export class CreateAccountComponent { passwordError = false; router = inject(Router); + authService = inject(AuthService); + snackBar = inject(MatSnackBar); ngOnInit() { this.passwordFormControl.valueChanges.subscribe((value) => { @@ -67,10 +74,28 @@ export class CreateAccountComponent { this.passwordError = false; if ( this.emailFormControl.valid && + this.firstNameFormControl.valid && + this.lastNameFormControl.valid && this.passwordFormControl.valid && this.confirmPasswordFormControl.valid ) { - this.router.navigate(['/se-connecter']); + this.authService + .createAccount({ + email: this.emailFormControl.value!, + last_name: this.lastNameFormControl.value!, + first_name: this.firstNameFormControl.value!, + password: this.passwordFormControl.value!, + }) + .subscribe({ + next: (success) => { + console.log('success', success); + this.snackBar.open('Compte créé', '', { duration: 2000 }); + this.router.navigate(['/se-connecter']); + }, + error: (error) => { + console.log('error', error); + }, + }); } } else { this.passwordError = true; diff --git a/front-end/src/app/pages/forget-password/forget-password.component.html b/front-end/src/app/pages/forget-password/forget-password.component.html index 57b38ca..37d1549 100644 --- a/front-end/src/app/pages/forget-password/forget-password.component.html +++ b/front-end/src/app/pages/forget-password/forget-password.component.html @@ -12,7 +12,7 @@ Une adresse email est nécessaire } - diff --git a/front-end/src/app/pages/home/home.component.html b/front-end/src/app/pages/home/home.component.html index 82844db..e4231cb 100644 --- a/front-end/src/app/pages/home/home.component.html +++ b/front-end/src/app/pages/home/home.component.html @@ -1,7 +1,20 @@ +
+
+ Regard d'Altitude est un projet ayant pour but la création d'un réseau + d'échanges entre acteurs de la montagne, des territoires et de la science + autour de la question des transformations de la montagne dans un contexte de + changement climatique. Notamment en permettant un inventaire collaboratif + des évènements en montagne. +
+ +
diff --git a/front-end/src/app/pages/login/login.component.scss b/front-end/src/app/pages/login/login.component.scss index 9a8820d..a62b7c4 100644 --- a/front-end/src/app/pages/login/login.component.scss +++ b/front-end/src/app/pages/login/login.component.scss @@ -12,3 +12,7 @@ mat-form-field { max-width: 400px; padding: 16px; } + +.login-error-container { + padding: 0px 16px 16px 16px; +} diff --git a/front-end/src/app/pages/login/login.component.ts b/front-end/src/app/pages/login/login.component.ts index e5ca340..27f5033 100644 --- a/front-end/src/app/pages/login/login.component.ts +++ b/front-end/src/app/pages/login/login.component.ts @@ -10,6 +10,7 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { Router, RouterLink } from '@angular/router'; import { AuthService } from '../../services/auth.service'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; @Component({ selector: 'app-login', @@ -21,6 +22,7 @@ import { AuthService } from '../../services/auth.service'; ReactiveFormsModule, MatButtonModule, RouterLink, + MatSnackBarModule, ], templateUrl: './login.component.html', styleUrl: './login.component.scss', @@ -37,13 +39,32 @@ export class LoginComponent { router = inject(Router); authService = inject(AuthService); + snackBar = inject(MatSnackBar); + + error: String | null = null; onLogin() { if (this.emailFormControl.valid && this.passwordFormControl.valid) { - this.authService.login(); - if (this.authService.isAuth.value) { - this.router.navigate(['..']); - } + this.authService + .login({ + email: this.emailFormControl.value!, + password: this.passwordFormControl.value!, + }) + .subscribe({ + next: (success: any) => { + console.log('success', success); + this.snackBar.open('Vous êtes connecté', '', { duration: 2000 }); + this.authService.saveToken(success.access); + this.authService.saveRefreshToken(success.refresh); + this.authService.checkAuth(); + this.router.navigate(['..']); + }, + error: (error: any) => { + console.log(error); + this.error = error.toString(); + console.log(this.error); + }, + }); } } } diff --git a/front-end/src/app/pages/my-account/my-account.component.html b/front-end/src/app/pages/my-account/my-account.component.html index eda628d..4632b32 100644 --- a/front-end/src/app/pages/my-account/my-account.component.html +++ b/front-end/src/app/pages/my-account/my-account.component.html @@ -1,7 +1,30 @@ diff --git a/front-end/src/app/pages/my-account/my-account.component.ts b/front-end/src/app/pages/my-account/my-account.component.ts index 66d078f..57912ee 100644 --- a/front-end/src/app/pages/my-account/my-account.component.ts +++ b/front-end/src/app/pages/my-account/my-account.component.ts @@ -1,10 +1,5 @@ import { Component, inject } from '@angular/core'; -import { - FormControl, - FormGroup, - FormsModule, - ReactiveFormsModule, -} from '@angular/forms'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatDialog } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; @@ -12,17 +7,21 @@ import { MatInputModule } from '@angular/material/input'; import { Router, RouterLink } from '@angular/router'; import { DeleteAccountDialog } from './dialogs/delete-account-dialog'; import { AuthService } from '../../services/auth.service'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { CommonModule } from '@angular/common'; @Component({ selector: 'app-my-account', standalone: true, imports: [ + CommonModule, FormsModule, MatFormFieldModule, MatInputModule, ReactiveFormsModule, MatButtonModule, RouterLink, + MatSnackBarModule, ], templateUrl: './my-account.component.html', styleUrl: './my-account.component.scss', @@ -30,16 +29,33 @@ import { AuthService } from '../../services/auth.service'; export class MyAccountComponent { readonly dialog = inject(MatDialog); email = 'adresse@email.com'; + lastName = 'Nom'; + firstName = 'Prénom'; + authService = inject(AuthService); router = inject(Router); + snackBar = inject(MatSnackBar); openDeleteAccountDialog() { const deleteDialogRef = this.dialog.open(DeleteAccountDialog); deleteDialogRef.afterClosed().subscribe((result) => { if (result && result.delete) { - this.authService.logout(); - this.router.navigate(['..']); + this.authService.deleteAccount().subscribe({ + next: (success: any) => { + console.log('success', success); + this.authService.removeToken(); + this.authService.removeRefreshToken(); + this.authService.checkAuth(); + this.snackBar.open('Votre compte est supprimé', '', { + duration: 2000, + }); + this.router.navigate(['..']); + }, + error: (error) => { + console.log('error', error); + }, + }); } }); } diff --git a/front-end/src/app/pages/my-observations/dialogs/my-observation-loader-dialog.html b/front-end/src/app/pages/my-observations/dialogs/my-observation-loader-dialog.html new file mode 100644 index 0000000..9d8c9a6 --- /dev/null +++ b/front-end/src/app/pages/my-observations/dialogs/my-observation-loader-dialog.html @@ -0,0 +1,4 @@ +

Transfert en cours

+ + + diff --git a/front-end/src/app/pages/my-observations/dialogs/my-observation-loader-dialog.scss b/front-end/src/app/pages/my-observations/dialogs/my-observation-loader-dialog.scss new file mode 100644 index 0000000..4715f66 --- /dev/null +++ b/front-end/src/app/pages/my-observations/dialogs/my-observation-loader-dialog.scss @@ -0,0 +1,4 @@ +.spinner-container { + display: flex; + justify-content: center; +} diff --git a/front-end/src/app/pages/my-observations/dialogs/my-observation-loader-dialog.ts b/front-end/src/app/pages/my-observations/dialogs/my-observation-loader-dialog.ts new file mode 100644 index 0000000..484c91b --- /dev/null +++ b/front-end/src/app/pages/my-observations/dialogs/my-observation-loader-dialog.ts @@ -0,0 +1,31 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { + MatDialogActions, + MatDialogClose, + MatDialogContent, + MatDialogRef, + MatDialogTitle, + MAT_DIALOG_DATA, +} from '@angular/material/dialog'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; + +@Component({ + selector: 'my-observation-loader-dialog', + templateUrl: 'my-observation-loader-dialog.html', + styleUrl: './my-observation-loader-dialog.scss', + standalone: true, + imports: [ + MatButtonModule, + MatDialogActions, + MatDialogClose, + MatDialogTitle, + MatDialogContent, + MatProgressSpinnerModule, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MyObservationLoaderDialog { + dialogRef = inject(MatDialogRef); + data = inject<{ title: string }>(MAT_DIALOG_DATA); +} diff --git a/front-end/src/app/pages/my-observations/my-observations.component.html b/front-end/src/app/pages/my-observations/my-observations.component.html index 76c3b06..81023c1 100644 --- a/front-end/src/app/pages/my-observations/my-observations.component.html +++ b/front-end/src/app/pages/my-observations/my-observations.component.html @@ -33,18 +33,21 @@
-
+
Mes observations
- @for (observation of myObservations; track observation.uuid) { + @for (observation of myObservations.features; track observation.id) { {{ - observation.name && observation.name !== "" - ? observation.name - : getEventType(observation.category)!.label + observation.properties.name && observation.properties.name !== "" + ? observation.properties.name + : getEventType(observation.properties.category)!.label }} {{ - observation.event_date | date: "dd/MM/yyyy" + observation.properties.event_date | date: "dd/MM/yyyy" }} -
- - - {{ area.offline ? "delete" : "download_for_offline" }} - - {{ area.name }} - - - } - +
+ + @for (area of areas; track area.id) { + +
+ +
+ + + {{ area.offline ? "delete" : "download_for_offline" }} + + {{ area.name }} + +
+ } +
+
diff --git a/front-end/src/app/pages/my-offline-data/my-offline-data.component.scss b/front-end/src/app/pages/my-offline-data/my-offline-data.component.scss index c52bbe0..840e9c8 100644 --- a/front-end/src/app/pages/my-offline-data/my-offline-data.component.scss +++ b/front-end/src/app/pages/my-offline-data/my-offline-data.component.scss @@ -7,9 +7,9 @@ mat-card { width: 240px; cursor: pointer; .area-type-icon { - font-size: 40px; - height: 40px; - width: 40px; + font-size: 72px; + height: 72px; + width: 72px; } } @@ -26,12 +26,11 @@ mat-card { } mat-grid-list { - margin-top: 24px; mat-grid-tile { overflow: visible; } } -.information-container { - padding: 16px; +.areas-container { + padding-top: 24px; } diff --git a/front-end/src/app/pages/my-offline-data/my-offline-data.component.ts b/front-end/src/app/pages/my-offline-data/my-offline-data.component.ts index 6bbcbd9..f753e46 100644 --- a/front-end/src/app/pages/my-offline-data/my-offline-data.component.ts +++ b/front-end/src/app/pages/my-offline-data/my-offline-data.component.ts @@ -11,9 +11,11 @@ import { MatDialog } from '@angular/material/dialog'; import { DownloadDialog } from './dialogs/download-dialog'; import { DeleteDialog } from './dialogs/delete-dialog'; -import areas from '../../../data/areas.json'; import { OfflineService } from '../../services/offline.service'; import { LoaderDialog } from './dialogs/loader-dialog'; +import { SettingsService } from '../../services/settings.service'; +import { environment } from '../../../environments/environment'; +import { Area } from '../../types/types'; @Component({ selector: 'app-my-offline-data', @@ -32,6 +34,7 @@ import { LoaderDialog } from './dialogs/loader-dialog'; export class MyOfflineDataComponent { readonly dialog = inject(MatDialog); offlineService = inject(OfflineService); + settingsService = inject(SettingsService); areas: any[] = []; @@ -79,8 +82,12 @@ export class MyOfflineDataComponent { } async initAreas() { - for (let index = 0; index < areas.length; index++) { - const area = areas[index]; + for ( + let index = 0; + index < this.settingsService.settings.value!.areas.length; + index++ + ) { + const area = this.settingsService.settings.value!.areas[index]; this.areas.push({ ...area, offline: Boolean( @@ -98,7 +105,7 @@ export class MyOfflineDataComponent { } } - openDownloadDialog(area: any) { + openDownloadDialog(area: Area) { const downloadDialogRef = this.dialog.open(DownloadDialog, { width: '250px', data: { name: area.name }, @@ -113,17 +120,25 @@ export class MyOfflineDataComponent { }); const { tileLayerOffline } = await import('leaflet.offline'); const L = await import('leaflet'); - const url = - 'https://data.geopf.fr/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=GEOGRAPHICALGRIDSYSTEMS.PLANIGNV2&STYLE=normal&FORMAT=image/png&TILEMATRIXSET=PM&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}'; - const attribution = "IGN"; - const minZoom = 0; - const maxZoom = 9; + const defaultLayer = this.settingsService.settings.value?.base_maps + .main_map.url + ? this.settingsService.settings.value?.base_maps.main_map.url + : environment.baseMaps.mainMap.url; + const defaultAttribution = this.settingsService.settings.value + ?.base_maps.main_map.attribution + ? this.settingsService.settings.value?.base_maps.main_map.attribution + : environment.baseMaps.mainMap.attribution; + const minZoom = area.min_zoom; + const maxZoom = area.max_zoom; const bounds = L.default.latLngBounds([ { lat: area.bbox[0][0], lng: area.bbox[0][1] }, { lat: area.bbox[1][0], lng: area.bbox[1][1] }, ]); - const offlineLayer = tileLayerOffline(url, { attribution }); + + const offlineLayer = tileLayerOffline(defaultLayer, { + attribution: defaultAttribution, + }); await this.offlineService.writeOrUpdateTilesInStore( offlineLayer, bounds, diff --git a/front-end/src/app/pages/new-observation/dialogs/new-observation-loader-dialog.html b/front-end/src/app/pages/new-observation/dialogs/new-observation-loader-dialog.html new file mode 100644 index 0000000..9d8c9a6 --- /dev/null +++ b/front-end/src/app/pages/new-observation/dialogs/new-observation-loader-dialog.html @@ -0,0 +1,4 @@ +

Transfert en cours

+ + + diff --git a/front-end/src/app/pages/new-observation/dialogs/new-observation-loader-dialog.scss b/front-end/src/app/pages/new-observation/dialogs/new-observation-loader-dialog.scss new file mode 100644 index 0000000..4715f66 --- /dev/null +++ b/front-end/src/app/pages/new-observation/dialogs/new-observation-loader-dialog.scss @@ -0,0 +1,4 @@ +.spinner-container { + display: flex; + justify-content: center; +} diff --git a/front-end/src/app/pages/new-observation/dialogs/new-observation-loader-dialog.ts b/front-end/src/app/pages/new-observation/dialogs/new-observation-loader-dialog.ts new file mode 100644 index 0000000..1150071 --- /dev/null +++ b/front-end/src/app/pages/new-observation/dialogs/new-observation-loader-dialog.ts @@ -0,0 +1,31 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { + MatDialogActions, + MatDialogClose, + MatDialogContent, + MatDialogRef, + MatDialogTitle, + MAT_DIALOG_DATA, +} from '@angular/material/dialog'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; + +@Component({ + selector: 'new-observation-loader-dialog', + templateUrl: 'new-observation-loader-dialog.html', + styleUrl: './new-observation-loader-dialog.scss', + standalone: true, + imports: [ + MatButtonModule, + MatDialogActions, + MatDialogClose, + MatDialogTitle, + MatDialogContent, + MatProgressSpinnerModule, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class NewObservationLoaderDialog { + dialogRef = inject(MatDialogRef); + data = inject<{ title: string }>(MAT_DIALOG_DATA); +} diff --git a/front-end/src/app/pages/new-observation/new-observation.component.html b/front-end/src/app/pages/new-observation/new-observation.component.html index 87ba9da..2c2d704 100644 --- a/front-end/src/app/pages/new-observation/new-observation.component.html +++ b/front-end/src/app/pages/new-observation/new-observation.component.html @@ -23,7 +23,10 @@ track observationsType.id ) { -
+
-
- +
+ @for (photo of photoForm.value.photos; track photo.file.name) { +
+ + +
+ }
@@ -104,7 +121,7 @@ Localisez mon observation
-
+
Latitude : {{ mapForm.value.position.lat }}
Longitude : {{ mapForm.value.position.lng }}
@@ -158,13 +175,17 @@ {{ typeForm.value.type.label }}
-
+
Contenu multimédia
-
- +
+ @for (photo of photoForm.value.photos; track photo) { +
+ +
+ }
-
+
Localisation
Latitude : {{ mapForm.value.position.lat }}
Longitude : {{ mapForm.value.position.lng }}
diff --git a/front-end/src/app/pages/new-observation/new-observation.component.scss b/front-end/src/app/pages/new-observation/new-observation.component.scss index 9176a76..36c7362 100644 --- a/front-end/src/app/pages/new-observation/new-observation.component.scss +++ b/front-end/src/app/pages/new-observation/new-observation.component.scss @@ -2,14 +2,13 @@ mat-card { display: flex; align-items: center; justify-content: center; - height: auto; + height: 136px; padding: 20px; width: 240px; cursor: pointer; .observation-type-icon { - font-size: 40px; - height: 40px; - width: 40px; + height: 72px; + width: 72px; } } @@ -31,11 +30,23 @@ mat-card { gap: 16px; } -.photo-container { - img { - width: 200px; - height: 200px; - object-fit: contain; +.photos-container { + display: flex; + overflow-x: scroll; + .photo-container { + position: relative; + .delete-photo-button { + position: absolute; + right: 0; + } + .photo { + width: 200px; + max-height: 200px; + object-fit: contain; + } + &:not(:last-of-type) { + margin-right: 16px; + } } } diff --git a/front-end/src/app/pages/new-observation/new-observation.component.ts b/front-end/src/app/pages/new-observation/new-observation.component.ts index 67fc7a4..ec8df66 100644 --- a/front-end/src/app/pages/new-observation/new-observation.component.ts +++ b/front-end/src/app/pages/new-observation/new-observation.component.ts @@ -4,6 +4,7 @@ import { ViewChild, inject, afterNextRender, + NgZone, } from '@angular/core'; import { CommonModule } from '@angular/common'; import { MatGridListModule } from '@angular/material/grid-list'; @@ -25,20 +26,29 @@ import { MatButtonModule } from '@angular/material/button'; import { Router, RouterLink } from '@angular/router'; import { Platform, PlatformModule } from '@angular/cdk/platform'; import { MatDatepickerModule } from '@angular/material/datepicker'; -import 'moment/locale/fr'; import { MatTooltipModule } from '@angular/material/tooltip'; +import 'moment/locale/fr'; import * as _moment from 'moment'; import { default as _rollupMoment } from 'moment'; import { round } from '@turf/helpers'; -import observationTypes from '../../../data/types.json'; import { + Icon, + Icons, Observation, + ObservationFeature, ObservationType, ObservationTypes, } from '../../types/types'; import { OfflineService } from '../../services/offline.service'; import { v4 as uuidv4 } from 'uuid'; +import { SettingsService } from '../../services/settings.service'; +import { ObservationsService } from '../../services/observations.service'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { firstValueFrom } from 'rxjs'; +import { NewObservationLoaderDialog } from './dialogs/new-observation-loader-dialog'; +import { MatDialog } from '@angular/material/dialog'; +import { environment } from '../../../environments/environment'; const moment = _rollupMoment || _moment; @@ -61,12 +71,12 @@ const moment = _rollupMoment || _moment; PlatformModule, MatDatepickerModule, MatTooltipModule, + MatSnackBarModule, ], templateUrl: './new-observation.component.html', styleUrl: './new-observation.component.scss', }) export class NewObservationComponent { - observationsTypes: ObservationTypes = observationTypes; observationTypeParent: ObservationType | null = null; columns: number = 2; breakpoints = { @@ -91,9 +101,9 @@ export class NewObservationComponent { }); photoForm: FormGroup<{ - photo: FormControl; + photos: FormControl<{ file: File; objectUrl: string }[] | null>; }> = new FormGroup({ - photo: new FormControl(null), + photos: new FormControl([]), }); mapForm: FormGroup<{ @@ -116,6 +126,15 @@ export class NewObservationComponent { platform = inject(Platform); router = inject(Router); offlineService = inject(OfflineService); + settingsService = inject(SettingsService); + observationsService = inject(ObservationsService); + snackBar = inject(MatSnackBar); + ngZone = inject(NgZone); + readonly dialog = inject(MatDialog); + + observationsTypes: ObservationTypes = []; + + icons: Icons = []; ngOnInit() { this.mobile = this.platform.ANDROID || this.platform.IOS; @@ -142,32 +161,63 @@ export class NewObservationComponent { } } }); + this.observationsTypes = this.settingsService.settings.value!.categories!; } constructor() { afterNextRender(async () => { - this.initMap(); + await this.initIcons(); + await this.initMap(); }); } async initMap() { this.L = await import('leaflet'); await import('leaflet.locatecontrol'); + const { tileLayerOffline } = await import('leaflet.offline'); this.map = this.L.map('map', { zoom: 4, center: [47, 2] }); - this.L.tileLayer( - 'https://data.geopf.fr/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=GEOGRAPHICALGRIDSYSTEMS.PLANIGNV2&STYLE=normal&FORMAT=image/png&TILEMATRIXSET=PM&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}', - { - attribution: "IGN", - }, - ).addTo(this.map); + const defaultLayerUrl = this.settingsService.settings.value?.base_maps + .main_map.url + ? this.settingsService.settings.value?.base_maps.main_map.url + : environment.baseMaps.mainMap.url; + const defaultLayerAttribution = this.settingsService.settings.value + ?.base_maps.main_map.attribution + ? this.settingsService.settings.value?.base_maps.main_map.attribution + : environment.baseMaps.mainMap.attribution; + + const satelliteLayerUrl = this.settingsService.settings.value?.base_maps + .satellite_map.url + ? this.settingsService.settings.value?.base_maps.satellite_map.url + : environment.baseMaps.satellitMap.url; + const satelliteLayerAttribution = this.settingsService.settings.value + ?.base_maps.satellite_map.attribution + ? this.settingsService.settings.value?.base_maps.satellite_map.attribution + : environment.baseMaps.satellitMap.attribution; + + const defaultLayer = tileLayerOffline(defaultLayerUrl, { + attribution: defaultLayerAttribution, + }); + defaultLayer.addTo(this.map); + const satelliteLayer = tileLayerOffline(satelliteLayerUrl, { + attribution: satelliteLayerAttribution, + }); + this.L.control + .layers( + { Defaut: defaultLayer, Satellite: satelliteLayer }, + {}, + { collapsed: true }, + ) + .addTo(this.map); this.map.on('move', (e: any) => { const center = this.map.getCenter(); - this.mapForm.value.position = { - lat: round(center.lat, 6), - lng: round(center.lng, 6), - }; + this.ngZone.run(() => { + this.mapForm.value.position = { + lat: round(center.lat, 6), + lng: round(center.lng, 6), + }; + }); this.marker.setLatLng(center); }); @@ -209,29 +259,45 @@ export class NewObservationComponent { } onPhotoSelected(event: any) { - const photo: File = event.target.files[0]; - - if (photo) { - this.photoForm.value.photo = URL.createObjectURL(photo); + const photoSelected: File = event.target.files[0]; + if ( + photoSelected && + this.photoForm.value.photos && + !this.photoForm.value.photos.find( + (photo) => photo.file.name === photoSelected.name, + ) + ) { + this.photoForm.value.photos.push({ + file: photoSelected, + objectUrl: URL.createObjectURL(photoSelected), + } as any); } + event.target.value = null; } backToPreviousObservations() { this.observationTypeParent = null; this.typeForm.setValue({ type: null }); - this.observationsTypes = observationTypes; + this.observationsTypes = this.settingsService.settings.value!.categories; } - saveAsDraft() { + async saveAsDraft() { const newObservation: Observation = { uuid: uuidv4(), name: this.moreDataForm.value.name!, - event_date: this.moreDataForm.value.date!.toDate().toString(), + event_date: moment(this.moreDataForm.value.date!.toDate()).format( + 'YYYY-MM-DD', + ), comments: this.moreDataForm.value.comment!, category: this.typeForm.value.type!.id, - source: 'Source', + source: undefined, + coordinates: [ + this.mapForm.value.position!.lng, + this.mapForm.value.position!.lat, + ], + files: this.photoForm.value.photos!.map((photo) => photo.file), }; - this.offlineService.writeOrUpdateDataInStore('observations', [ + await this.offlineService.writeOrUpdateDataInStore('observations', [ newObservation, ]); this.offlineService.handleObservationsPending(); @@ -239,16 +305,97 @@ export class NewObservationComponent { } sendObservation() { - this.router.navigate(['/']); + const newObservationLoaderDialogRef = this.dialog.open( + NewObservationLoaderDialog, + { + width: '250px', + data: { title: 'Téléchargement en cours' }, + disableClose: true, + }, + ); + const observation: ObservationFeature = { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [ + this.mapForm.value.position!.lng, + this.mapForm.value.position!.lat, + ], + }, + properties: { + comments: this.moreDataForm.value.comment ?? '', + event_date: moment(this.moreDataForm.value.date.toDate()).format( + 'YYYY-MM-DD', + ), + category: this.typeForm.value.type!.id, + }, + }; + if (Boolean(this.moreDataForm.value.name)) { + observation.properties.name = this.moreDataForm.value.name!; + } + this.observationsService.sendObservation(observation).subscribe({ + next: async (observationResponse: any) => { + for ( + let index = 0; + index < this.photoForm.value.photos!.length; + index++ + ) { + const photo = this.photoForm.value.photos![index]; + await firstValueFrom( + this.observationsService.sendPhotoObservation( + observationResponse.id, + photo.file, + ), + ); + } + newObservationLoaderDialogRef.close(); + this.snackBar.open('Observation transférée', '', { duration: 2000 }); + this.router.navigate(['/']); + }, + error: async () => { + newObservationLoaderDialogRef.close(); + this.snackBar.open( + "Une erreur est survenue lors du transfert de l'observation", + '', + { + duration: 2000, + }, + ); + await this.saveAsDraft(); + }, + }); } getEventType(eventTypeId: number) { const eventTypes = [ - ...observationTypes.map((type) => type), - ...observationTypes.map((type) => type.children).flat(), + ...this.settingsService.settings.value!.categories.map((type) => type), + ...this.settingsService.settings + .value!.categories.map((type) => type.children) + .flat(), ]; return eventTypes.find( (observationType) => observationType.id === eventTypeId, ); } + + deletePhoto(selectedPhoto: any) { + this.photoForm.value.photos!.splice( + this.photoForm.value.photos!.findIndex( + (photo) => photo.file.name === selectedPhoto.file.name, + ), + 1, + ); + } + + async initIcons() { + const icons = await this.offlineService.getAllDataInStore('icons'); + this.icons = icons.map((icon) => ({ + ...icon, + objectUrl: URL.createObjectURL(icon.file), + })); + } + + getIconFromStorage(iconId: number) { + return this.icons.find((icon) => icon.id === iconId)!.objectUrl; + } } diff --git a/front-end/src/app/pages/observation-detail/observation-detail.component.html b/front-end/src/app/pages/observation-detail/observation-detail.component.html index 138bab9..8c50cc9 100644 --- a/front-end/src/app/pages/observation-detail/observation-detail.component.html +++ b/front-end/src/app/pages/observation-detail/observation-detail.component.html @@ -1,30 +1,40 @@ -
- +
-
-
- {{ - observationData.properties.name && observationData.properties.name !== "" - ? observationData.properties.name - : observationType.label - }} -
-
- {{ observationType.label }} -
-
- Date : {{ observationData.properties.event_date }} -
-
- Source : {{ observationData.properties.source }} -
-
- {{ observationData.properties.comments }} -
+ > + @for (media of observationData.properties.medias; track media.uuid) { + + } +
+
+ {{ + observationData.properties.name && observationData.properties.name !== "" + ? observationData.properties.name + : observationType.label + }} +
+
+ {{ observationType.label }} +
+
+ Date : {{ + observationData.properties.event_date | date: "dd/MM/yyyy" + }} +
+
+ Source : {{ + observationData.properties.source + ? observationData.properties.source + : "Inconnue" + }} +
+
+ {{ observationData.properties.comments }} +
+ diff --git a/front-end/src/app/pages/observation-detail/observation-detail.component.scss b/front-end/src/app/pages/observation-detail/observation-detail.component.scss index b693459..4a3d8e0 100644 --- a/front-end/src/app/pages/observation-detail/observation-detail.component.scss +++ b/front-end/src/app/pages/observation-detail/observation-detail.component.scss @@ -1,11 +1,14 @@ -.photo-container { +.photos-container { display: flex; - justify-content: center; + overflow-x: scroll; width: 100%; height: 400px; img { width: 100%; object-fit: contain; + &:not(:last-of-type) { + margin-right: 16px; + } } } .source-container { diff --git a/front-end/src/app/pages/observation-detail/observation-detail.component.ts b/front-end/src/app/pages/observation-detail/observation-detail.component.ts index 8b0307a..d71f3ff 100644 --- a/front-end/src/app/pages/observation-detail/observation-detail.component.ts +++ b/front-end/src/app/pages/observation-detail/observation-detail.component.ts @@ -1,9 +1,9 @@ -import { Component, Input } from '@angular/core'; +import { Component, inject, Input } from '@angular/core'; +import { CommonModule } from '@angular/common'; -import evenementsRemarquables from '../../../data/evenements_remarquables.json'; -import observationTypes from '../../../data/types.json'; import { Observation, ObservationType } from '../../types/types'; -import { CommonModule } from '@angular/common'; +import { ObservationsService } from '../../services/observations.service'; +import { SettingsService } from '../../services/settings.service'; @Component({ selector: 'app-observation-detail', @@ -17,20 +17,31 @@ export class ObservationDetailComponent { observationData!: { properties: Observation }; observationType!: ObservationType; + observationsService = inject(ObservationsService); + settingsService = inject(SettingsService); + ngOnInit() { - const observationId = Number(this.observation.split('-')[0]); - this.observationData = (evenementsRemarquables.features as any).find( - (feature: any) => feature.properties.uuid === observationId, - ); - this.observationType = this.getEventType( - this.observationData!.properties.category, - )!; + const observationId = this.observation.slice(0, 36); + this.observationsService.getObservation(observationId).subscribe({ + next: (success: any) => { + console.log('success', success); + this.observationData = success; + this.observationType = this.getEventType( + this.observationData!.properties.category, + )!; + }, + error: (error) => { + console.log('error', error); + }, + }); } getEventType(eventTypeId: number) { const eventTypes = [ - ...observationTypes.map((type) => type), - ...observationTypes.map((type) => type.children).flat(), + ...this.settingsService.settings.value!.categories.map((type) => type), + ...this.settingsService.settings + .value!.categories.map((type) => type.children) + .flat(), ]; return eventTypes.find( (observationType) => observationType.id === eventTypeId, diff --git a/front-end/src/app/pages/synthesis-interface/dialogs/filter-dialog.html b/front-end/src/app/pages/synthesis-interface/dialogs/filter-dialog.html index 6433cc2..4aa41f2 100644 --- a/front-end/src/app/pages/synthesis-interface/dialogs/filter-dialog.html +++ b/front-end/src/app/pages/synthesis-interface/dialogs/filter-dialog.html @@ -4,9 +4,9 @@

Filtrer

Types d'observation - @for (observationType of observationTypes; track observationType.id) { + @for (observationType of observationsTypes; track observationType.id) { {{observationType.name}}{{observationType.label}} } diff --git a/front-end/src/app/pages/synthesis-interface/dialogs/filter-dialog.ts b/front-end/src/app/pages/synthesis-interface/dialogs/filter-dialog.ts index 89115e9..98be1b8 100644 --- a/front-end/src/app/pages/synthesis-interface/dialogs/filter-dialog.ts +++ b/front-end/src/app/pages/synthesis-interface/dialogs/filter-dialog.ts @@ -17,8 +17,8 @@ import { import { MatFormFieldModule } from '@angular/material/form-field'; import { MatSelectModule } from '@angular/material/select'; import { MatDatepickerModule } from '@angular/material/datepicker'; - -import observationsTypes from '../../../../data/types.json'; +import { SettingsService } from '../../../services/settings.service'; +import { ObservationTypes } from '../../../types/types'; @Component({ selector: 'filter-dialog', @@ -46,12 +46,16 @@ export class FilterDialog { }>(MAT_DIALOG_DATA); dialogRef = inject(MatDialogRef); observationsTypesForm = new FormControl(this.data.observationTypes); - observationTypes: any = observationsTypes; readonly observationsDates = new FormGroup({ start: new FormControl(this.data.observationDates.start), end: new FormControl(this.data.observationDates.end), }); + settingsService = inject(SettingsService); + + observationsTypes: ObservationTypes = + this.settingsService.settings.value!.categories; + filterCancel() { this.dialogRef.close({ cancel: true }); } diff --git a/front-end/src/app/pages/synthesis-interface/synthesis-interface.component.html b/front-end/src/app/pages/synthesis-interface/synthesis-interface.component.html index d14941e..cf59d13 100644 --- a/front-end/src/app/pages/synthesis-interface/synthesis-interface.component.html +++ b/front-end/src/app/pages/synthesis-interface/synthesis-interface.component.html @@ -9,7 +9,12 @@
- + @for ( observation of currentObservationsFeatureCollection.features; - track observation.properties.uuid + track observation.id ) { diff --git a/front-end/src/app/pages/synthesis-interface/synthesis-interface.component.scss b/front-end/src/app/pages/synthesis-interface/synthesis-interface.component.scss index 2232f41..b6bd9e9 100644 --- a/front-end/src/app/pages/synthesis-interface/synthesis-interface.component.scss +++ b/front-end/src/app/pages/synthesis-interface/synthesis-interface.component.scss @@ -5,12 +5,16 @@ $panel-height: 100vh; display: flex; justify-content: center; position: absolute; - bottom: calc(16px + 48px + 56px); + bottom: calc(16px + 48px); z-index: 400; + pointer-events: none; + button { + pointer-events: all; + } } #map { - height: calc(100% - 48px - 56px); + height: calc(100% - 48px); width: 100%; } diff --git a/front-end/src/app/pages/synthesis-interface/synthesis-interface.component.ts b/front-end/src/app/pages/synthesis-interface/synthesis-interface.component.ts index e57c9e1..38f4fec 100644 --- a/front-end/src/app/pages/synthesis-interface/synthesis-interface.component.ts +++ b/front-end/src/app/pages/synthesis-interface/synthesis-interface.component.ts @@ -19,10 +19,12 @@ import { import { CommonModule } from '@angular/common'; import { MatListModule } from '@angular/material/list'; import moment from 'moment'; -import { Observations } from '../../types/types'; +import { observationsFeatureCollection } from '../../types/types'; -import evenementsRemarquables from '../../../data/evenements_remarquables.json'; -import observationTypes from '../../../data/types.json'; +import { ObservationsService } from '../../services/observations.service'; +import { SettingsService } from '../../services/settings.service'; +import { environment } from '../../../environments/environment'; +import { firstValueFrom } from 'rxjs'; @Component({ selector: 'app-synthesis-interface', @@ -49,18 +51,21 @@ export class SynthesisInterfaceComponent { L: any; map: any; - observationsFeatureCollection: { features: Observations } = - evenementsRemarquables as any; - currentObservationsFeatureCollection: any = evenementsRemarquables; - observationsFeatureCollectionFiltered: any = evenementsRemarquables; + observationsFeatureCollection: observationsFeatureCollection | null = null; + currentObservationsFeatureCollection: observationsFeatureCollection | null = + null; + observationsFeatureCollectionFiltered: observationsFeatureCollection | null = + null; observationsLayer: L.GeoJSON | null = null; observationsClusterGroup: any; router = inject(Router); + observationsService = inject(ObservationsService); + settingsService = inject(SettingsService); expansionPanelIsOpen = false; bounds: any; - private ngZone = inject(NgZone); + ngZone = inject(NgZone); handleObservationsWithinBoundsBind = this.handleObservationsWithinBounds.bind(this); @@ -77,16 +82,39 @@ export class SynthesisInterfaceComponent { this.L = await import('leaflet'); await import('leaflet.locatecontrol'); await import('leaflet.markercluster'); - const tileLayerOffline = await import('leaflet.offline'); + const { tileLayerOffline } = await import('leaflet.offline'); this.map = this.L.default.map('map', { zoom: 4, center: [47, 2] }); + const defaultLayerUrl = this.settingsService.settings.value?.base_maps + .main_map.url + ? this.settingsService.settings.value?.base_maps.main_map.url + : environment.baseMaps.mainMap.url; + const defaultLayerAttribution = this.settingsService.settings.value + ?.base_maps.main_map.attribution + ? this.settingsService.settings.value?.base_maps.main_map.attribution + : environment.baseMaps.mainMap.attribution; - this.L.default - .tileLayerOffline( - 'https://data.geopf.fr/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=GEOGRAPHICALGRIDSYSTEMS.PLANIGNV2&STYLE=normal&FORMAT=image/png&TILEMATRIXSET=PM&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}', - { - attribution: "IGN", - }, + const satelliteLayerUrl = this.settingsService.settings.value?.base_maps + .satellite_map.url + ? this.settingsService.settings.value?.base_maps.satellite_map.url + : environment.baseMaps.satellitMap.url; + const satelliteLayerAttribution = this.settingsService.settings.value + ?.base_maps.satellite_map.attribution + ? this.settingsService.settings.value?.base_maps.satellite_map.attribution + : environment.baseMaps.satellitMap.attribution; + + const defaultLayer = tileLayerOffline(defaultLayerUrl, { + attribution: defaultLayerAttribution, + }); + defaultLayer.addTo(this.map); + const satelliteLayer = tileLayerOffline(satelliteLayerUrl, { + attribution: satelliteLayerAttribution, + }); + this.L.control + .layers( + { Defaut: defaultLayer, Satellite: satelliteLayer }, + {}, + { collapsed: true }, ) .addTo(this.map); @@ -94,49 +122,68 @@ export class SynthesisInterfaceComponent { .locate({ setView: 'once', showPopup: false }) .addTo(this.map); - this.observationsLayer = this.L.default.geoJSON( - this.observationsFeatureCollection, - { - pointToLayer: (geoJsonPoint: any, latlng: any) => - this.L.default.marker(latlng, { - icon: this.L.default.divIcon({ - html: `
- -
`, - className: 'observation-marker', - iconSize: 32, - iconAnchor: [18, 28], - } as any), - autoPanOnFocus: false, - } as any), - onEachFeature: (geoJsonPoint: any, layer: any) => { - layer.once('click', () => { - this.handleObservationPopup(geoJsonPoint, layer); - }); - }, - }, - ); + this.observationsService.getObservations().subscribe({ + next: (success: any) => { + console.log('success', success); + this.ngZone.run(() => { + this.observationsFeatureCollection = success; + this.currentObservationsFeatureCollection = success; + this.observationsFeatureCollectionFiltered = success; + }); + this.observationsLayer = this.L.default.geoJSON( + this.observationsFeatureCollection, + { + pointToLayer: (geoJsonPoint: any, latlng: any) => { + const icon = this.getEventType(geoJsonPoint.properties.category); + return this.L.default.marker(latlng, { + icon: this.L.default.divIcon({ + html: + icon && icon.pictogram + ? `
+ +
` + : `
+
`, + className: 'observation-marker', + iconSize: 32, + iconAnchor: [18, 28], + } as any), + autoPanOnFocus: false, + } as any); + }, + onEachFeature: (geoJsonPoint: any, layer: any) => { + layer.once('click', () => { + this.handleObservationPopup(geoJsonPoint, layer); + }); + }, + }, + ); - this.observationsClusterGroup = this.L.default.markerClusterGroup({ - showCoverageOnHover: false, - removeOutsideVisibleBounds: false, - iconCreateFunction: (cluster: any) => { - return this.L.default.divIcon({ - html: - '
' + - cluster.getChildCount() + - '
', - className: '', - iconSize: 48, - } as any); - }, - }); + this.observationsClusterGroup = this.L.default.markerClusterGroup({ + showCoverageOnHover: false, + removeOutsideVisibleBounds: false, + iconCreateFunction: (cluster: any) => { + return this.L.default.divIcon({ + html: + '
' + + cluster.getChildCount() + + '
', + className: '', + iconSize: 48, + } as any); + }, + }); - this.observationsClusterGroup.addLayer(this.observationsLayer); - this.map.addLayer(this.observationsClusterGroup); + this.observationsClusterGroup.addLayer(this.observationsLayer); + this.map.addLayer(this.observationsClusterGroup); - this.fitToCurrentObservations(); - this.map.on('moveend', this.handleObservationsWithinBoundsBind); + this.fitToCurrentObservations(); + this.map.on('moveend', this.handleObservationsWithinBoundsBind); + }, + error: (error) => { + console.log('error', error); + }, + }); } handleObservationPopup(geoJsonPoint: any, layer?: any) { @@ -157,7 +204,9 @@ export class SynthesisInterfaceComponent { observationPopup.appendChild(observationName); const observationDate = this.L.default.DomUtil.create('div'); - observationDate.innerHTML = geoJsonPoint.properties.event_date; + observationDate.innerHTML = moment( + geoJsonPoint.properties.event_date, + ).format('DD-MM-YYYY'); observationDate.className = 'observation-date'; observationPopup.appendChild(observationDate); @@ -166,7 +215,7 @@ export class SynthesisInterfaceComponent { observationButton.className = 'observation-button'; observationButton.onclick = () => { const slug = slugify( - `${geoJsonPoint.properties.uuid}-${geoJsonPoint.properties.name}`, + `${geoJsonPoint.id}-${geoJsonPoint.properties.name}`, ); this.router.navigate(['/detail-d-une-observation', slug]); }; @@ -200,7 +249,7 @@ export class SynthesisInterfaceComponent { data: this.filter, }); - deleteDialogRef.afterClosed().subscribe((result) => { + deleteDialogRef.afterClosed().subscribe(async (result) => { if ( result && result.filter && @@ -209,62 +258,27 @@ export class SynthesisInterfaceComponent { (result.filter.observationDates.start && result.filter.observationDates.end)) ) { - let observationFeatures = null; - - if ( - Boolean(result.filter.observationTypes) && - result.filter.observationTypes.length > 0 - ) { - this.filter.observationTypes = result.filter.observationTypes; - observationFeatures = - this.observationsFeatureCollection.features.filter((feature: any) => - result.filter.observationTypes - .map((observationType: any) => observationType.id) - .includes(feature.properties.id_event_type), - ); - } - if ( - Boolean( - result.filter.observationDates && - result.filter.observationDates.start && - result.filter.observationDates.end, - ) - ) { - this.filter.observationTypes = result.filter.observationTypes; - if (observationFeatures) { - observationFeatures = observationFeatures.filter( - (observationFeature: any) => - moment(observationFeature.properties.date_event).isBetween( - result.filter.observationDates.start, - result.filter.observationDates.end, - null, - '[]', - ), - ); - } else { - observationFeatures = - this.observationsFeatureCollection.features.filter( - (observationFeature: any) => - moment(observationFeature.properties.date_event).isBetween( - result.filter.observationDates.start, - result.filter.observationDates.end, - null, - '[]', - ), - ); - } - } - this.observationsFeatureCollectionFiltered = { - ...this.observationsFeatureCollection, - features: observationFeatures || [], - }; - } else { - this.filter = { - observationTypes: [], - observationDates: { start: null, end: null }, - }; + const observations = await firstValueFrom( + this.observationsService.getObservations( + result.filter.observationTypes + ? result.filter.observationTypes.map( + (observationType: any) => observationType.id, + ) + : undefined, + result.filter.observationDates.start + ? moment(result.filter.observationDates.start.toDate()).format( + 'YYYY-MM-DD', + ) + : undefined, + result.filter.observationDates.end + ? moment(result.filter.observationDates.end.toDate()).format( + 'YYYY-MM-DD', + ) + : undefined, + ), + ); this.observationsFeatureCollectionFiltered = - this.observationsFeatureCollection; + observations as observationsFeatureCollection; } if (result && !result.cancel) { this.updateMap(); @@ -282,9 +296,9 @@ export class SynthesisInterfaceComponent { } fitToCurrentObservations() { - if (this.observationsFeatureCollectionFiltered.features.length > 0) { + if (this.observationsFeatureCollectionFiltered!.features.length > 0) { this.bounds = this.L.default.latLngBounds( - this.observationsFeatureCollectionFiltered.features.map( + this.observationsFeatureCollectionFiltered!.features.map( (feature: any) => [ feature.geometry.coordinates[1], feature.geometry.coordinates[0], @@ -294,13 +308,14 @@ export class SynthesisInterfaceComponent { this.bounds && this.map.fitBounds(this.bounds); } + this.handleObservationsWithinBounds(); } handleObservationsWithinBounds() { this.ngZone.run(() => { this.currentObservationsFeatureCollection = { - ...this.currentObservationsFeatureCollection, - features: this.observationsFeatureCollectionFiltered.features.filter( + ...this.currentObservationsFeatureCollection!, + features: this.observationsFeatureCollectionFiltered!.features.filter( (feature: any) => this.map .getBounds() @@ -329,15 +344,19 @@ export class SynthesisInterfaceComponent { updateMap() { this.observationsLayer!.clearLayers(); - this.observationsLayer!.addData(this.observationsFeatureCollectionFiltered); + this.observationsLayer!.addData( + this.observationsFeatureCollectionFiltered!, + ); this.observationsClusterGroup.clearLayers(); this.observationsClusterGroup.addLayer(this.observationsLayer); } getEventType(eventTypeId: number) { const eventTypes = [ - ...observationTypes.map((type) => type), - ...observationTypes.map((type) => type.children).flat(), + ...this.settingsService.settings.value!.categories.map((type) => type), + ...this.settingsService.settings + .value!.categories.map((type) => type.children) + .flat(), ]; return eventTypes.find( (observationType) => observationType.id === eventTypeId, diff --git a/front-end/src/app/services/auth.service.ts b/front-end/src/app/services/auth.service.ts index c743835..c883b9a 100644 --- a/front-end/src/app/services/auth.service.ts +++ b/front-end/src/app/services/auth.service.ts @@ -1,26 +1,116 @@ import { Injectable } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; import { inject } from '@angular/core'; import { BehaviorSubject } from 'rxjs'; +import { User } from '../types/types'; +import { OfflineService } from './offline.service'; + +const httpOptions = { + headers: new HttpHeaders({ + 'Content-Type': 'application/json', + 'Accept-Language': 'fr-FR', + }), +}; @Injectable({ providedIn: 'root', }) export class AuthService { + apiUrl = (import.meta as any).env.NG_APP_API_URL; httpClient = inject(HttpClient); + offlineService = inject(OfflineService); + isAuth = new BehaviorSubject(false); + user = new BehaviorSubject(null); checkAuth() { - this.isAuth.next(localStorage.getItem('token') !== null); + this.isAuth.next(localStorage.getItem('access_token') !== null); } - login() { - localStorage.setItem('token', 'token'); - this.checkAuth(); + getAccount() { + return this.httpClient.get(`${this.apiUrl}/api/accounts/me/`, httpOptions); + } + + login(account: { email: string; password: string }) { + return this.httpClient.post( + `${this.apiUrl}/api/token/`, + account, + httpOptions, + ); } logout() { - localStorage.removeItem('token'); + this.offlineService.resetObservationsPending(); + this.removeToken(); + this.removeRefreshToken(); this.checkAuth(); } + + createAccount(account: { + email: string; + last_name: string; + first_name: string; + password: string; + }) { + return this.httpClient.post( + `${this.apiUrl}/api/accounts/sign-up/`, + account, + httpOptions, + ); + } + + deleteAccount() { + return this.httpClient.delete( + `${this.apiUrl}/api/accounts/me/`, + httpOptions, + ); + } + + changePassword(password: string) { + return this.httpClient.patch( + `${this.apiUrl}/api/accounts/me/`, + { password }, + httpOptions, + ); + } + + resetPassword() {} + + refreshToken(refreshRoken: string) { + return this.httpClient.post( + `${this.apiUrl}/api/token/refresh/`, + { + refresh: refreshRoken, + }, + httpOptions, + ); + } + + getToken() { + return localStorage.getItem('access_token'); + } + + getRefreshToken() { + return localStorage.getItem('refresh_token'); + } + + saveToken(token: string) { + localStorage.setItem('access_token', token); + } + + saveRefreshToken(token: string) { + localStorage.setItem('refresh_token', token); + } + + removeToken() { + localStorage.removeItem('access_token'); + } + + removeRefreshToken() { + localStorage.removeItem('refresh_token'); + } + + setUser(user: User) { + this.user.next(user); + } } diff --git a/front-end/src/app/services/observations.service.spec.ts b/front-end/src/app/services/observations.service.spec.ts new file mode 100644 index 0000000..517bc98 --- /dev/null +++ b/front-end/src/app/services/observations.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { ObservationsService } from './observations.service'; + +describe('ObservationsService', () => { + let service: ObservationsService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(ObservationsService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/front-end/src/app/services/observations.service.ts b/front-end/src/app/services/observations.service.ts new file mode 100644 index 0000000..d7056cf --- /dev/null +++ b/front-end/src/app/services/observations.service.ts @@ -0,0 +1,74 @@ +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; +import { ObservationFeature } from '../types/types'; + +const httpOptions = { + headers: new HttpHeaders({ + 'Content-Type': 'application/json', + 'Accept-Language': 'fr-FR', + }), +}; + +@Injectable({ + providedIn: 'root', +}) +export class ObservationsService { + apiUrl = (import.meta as any).env.NG_APP_API_URL; + httpClient = inject(HttpClient); + + constructor() {} + + getObservations( + observationTypesId?: number[], + startDate?: string, + endDate?: string, + ) { + let url = `${this.apiUrl}/api/observations/`; + if (observationTypesId) { + for (let index = 0; index < observationTypesId.length; index++) { + const observationTypeId = observationTypesId[index]; + url = url.concat( + `${index === 0 ? '?' : '&'}categories=${observationTypeId}`, + ); + } + } + if (startDate && endDate) { + url = url.concat( + `${observationTypesId ? '&' : '?'}event_date_after=${startDate}&event_date_before=${endDate}`, + ); + } + return this.httpClient.get(`${url}`, httpOptions); + } + + getObservation(observationId: string) { + return this.httpClient.get( + `${this.apiUrl}/api/observations/${observationId}/`, + httpOptions, + ); + } + + getMyObservations() { + return this.httpClient.get( + `${this.apiUrl}/api/accounts/me/observations/`, + httpOptions, + ); + } + + sendObservation(observation: ObservationFeature) { + return this.httpClient.post( + `${this.apiUrl}/api/accounts/me/observations/`, + { ...observation }, + httpOptions, + ); + } + + sendPhotoObservation(observationId: any, file: any) { + const formData = new FormData(); + formData.append('media_file', file); + formData.append('media_type', 'image'); + return this.httpClient.post( + `${this.apiUrl}/api/accounts/me/observations/${observationId}/medias/`, + formData, + ); + } +} diff --git a/front-end/src/app/services/offline.service.ts b/front-end/src/app/services/offline.service.ts index f191259..1e7abd9 100644 --- a/front-end/src/app/services/offline.service.ts +++ b/front-end/src/app/services/offline.service.ts @@ -1,6 +1,14 @@ import { Injectable } from '@angular/core'; import { DBSchema, openDB } from 'idb'; -import { Area, Areas, Observation, Observations } from '../types/types'; +import { + Area, + Areas, + Icon, + Icons, + Observation, + Observations, + OfflineSettings, +} from '../types/types'; import { BehaviorSubject } from 'rxjs'; import { TileInfo, TileLayerOffline } from 'leaflet.offline'; @@ -8,7 +16,7 @@ type ObjectStores = ObjectStore[]; type ObjectStore = { name: ObjectStoresName; keyPath: KeyPath }; -type ObjectStoresName = 'observations' | 'offline-areas'; +type ObjectStoresName = 'observations' | 'offline-areas' | 'settings' | 'icons'; type KeyPath = 'uuid' | 'id'; @@ -16,9 +24,13 @@ type ObjectStoresType = T extends 'observations' ? Observation : T extends 'offline-areas' ? Area - : never; + : T extends 'settings' + ? OfflineSettings + : T extends 'icons' + ? Icon + : never; -type ObjectStoresData = Observations | Areas; +type ObjectStoresData = Observations | Areas | OfflineSettings[] | Icons; interface DB extends DBSchema { observations: { @@ -29,6 +41,14 @@ interface DB extends DBSchema { value: Area; key: number; }; + settings: { + value: OfflineSettings; + key: number; + }; + icons: { + value: Icon; + key: number; + }; } const dbVersion = 1; @@ -39,6 +59,10 @@ const dbVersion = 1; export class OfflineService { observationsPending = new BehaviorSubject(null); + resetObservationsPending() { + this.observationsPending.next(null); + } + async handleObservationsPending() { const observationsPending = (await this.getAllDataInStore('observations')) .length; @@ -57,6 +81,8 @@ export class OfflineService { const objectStoresNames: ObjectStores = [ { name: 'observations', keyPath: 'uuid' }, { name: 'offline-areas', keyPath: 'id' }, + { name: 'settings', keyPath: 'id' }, + { name: 'icons', keyPath: 'id' }, ]; objectStoresNames.forEach((objectStoresName) => { diff --git a/front-end/src/app/services/settings.service.spec.ts b/front-end/src/app/services/settings.service.spec.ts new file mode 100644 index 0000000..359cb6b --- /dev/null +++ b/front-end/src/app/services/settings.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { SettingsService } from './settings.service'; + +describe('SettingsService', () => { + let service: SettingsService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(SettingsService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/front-end/src/app/services/settings.service.ts b/front-end/src/app/services/settings.service.ts new file mode 100644 index 0000000..15686ca --- /dev/null +++ b/front-end/src/app/services/settings.service.ts @@ -0,0 +1,78 @@ +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { inject, Injectable, PLATFORM_ID } from '@angular/core'; +import { BehaviorSubject, firstValueFrom } from 'rxjs'; +import { Settings } from '../types/types'; +import { OfflineService } from './offline.service'; +import { isPlatformBrowser } from '@angular/common'; + +const httpOptions = { + headers: new HttpHeaders({ + 'Content-Type': 'application/json', + 'Accept-Language': 'fr-FR', + }), +}; + +const httpIconOptions = { + headers: new HttpHeaders({ Accept: 'image/svg+xml' }), +}; + +@Injectable({ + providedIn: 'root', +}) +export class SettingsService { + apiUrl = (import.meta as any).env.NG_APP_API_URL; + httpClient = inject(HttpClient); + settings = new BehaviorSubject(null); + offlineService = inject(OfflineService); + platformId = inject(PLATFORM_ID); + platformIsBrowser: boolean; + + constructor() { + this.platformIsBrowser = isPlatformBrowser(this.platformId); + } + + getSettings() { + return this.httpClient.get(`${this.apiUrl}/api/settings/`, httpOptions); + } + + async setSettings(settings: Settings) { + if (this.platformIsBrowser) { + await this.offlineService.writeOrUpdateDataInStore('settings', [ + { + id: 1, + settings, + }, + ]); + for (let index = 0; index < settings.categories.length; index++) { + const category = settings.categories[index]; + if (!(await this.offlineService.getDataInStore('icons', category.id))) { + const file: any = await firstValueFrom( + this.getIcon(category.pictogram), + ); + await this.offlineService.writeOrUpdateDataInStore('icons', [ + { id: category.id, pictogram: category.pictogram, file }, + ]); + } + } + } + console.log(settings); + this.settings.next(settings); + } + + async useOfflineSettings() { + if (this.platformIsBrowser) { + const { settings } = await this.offlineService.getDataInStore( + 'settings', + 1, + ); + this.settings.next(settings); + } + } + + getIcon(url: any) { + return this.httpClient.get(url, { + ...httpIconOptions, + responseType: 'blob', + }); + } +} diff --git a/front-end/src/app/types/types.ts b/front-end/src/app/types/types.ts index 1112cf2..5e1d03b 100644 --- a/front-end/src/app/types/types.ts +++ b/front-end/src/app/types/types.ts @@ -1,14 +1,32 @@ +export type observationsFeatureCollection = { + type: 'FeatureCollection'; + features: ObservationFeature[]; +}; + +export type ObservationFeature = { + type: 'Feature'; + id?: string; + geometry: { + type: 'Point'; + coordinates: number[]; + }; + properties: Observation; +}; + export type Observations = Observation[]; export type Observation = { - uuid: string; - name: string; + uuid?: string; + name?: string; comments: string; event_date: string; - source: string; + source?: string; category: number; + picture?: string; main_picture?: Picture; medias?: Picture[]; + coordinates?: number[]; + files?: File[]; }; export type Picture = { @@ -41,11 +59,29 @@ export type Area = { name: string; description: string; bbox: number[][]; + min_zoom: number; + max_zoom: number; + offline?: boolean; }; -export type settings = { +export type Settings = { categories: ObservationTypes; areas: Areas; + base_maps: { + main_map: { + url: string; + attribution: string; + }; + satellite_map: { + url: string; + attribution: string; + }; + }; +}; + +export type OfflineSettings = { + id: number; + settings: Settings; }; export type User = { @@ -56,3 +92,12 @@ export type User = { last_name: string; first_name: string; }; + +export type Icons = Icon[]; + +export type Icon = { + id: number; + pictogram: string; + file: File; + objectUrl?: string; +}; diff --git a/front-end/src/data/areas.json b/front-end/src/data/areas.json deleted file mode 100644 index 7b5fe36..0000000 --- a/front-end/src/data/areas.json +++ /dev/null @@ -1,56 +0,0 @@ -[ - { - "id": 1, - "name": "Zone A", - "description": "tooltip", - "bbox": [ - [45.933960441921585, 7.053222656250001], - [44.47299117260252, 5.921630859375001] - ] - }, - { - "id": 2, - "name": "Zone B", - "tooltip": "tooltip", - "bbox": [ - [45.933960441921585, 7.053222656250001], - [44.47299117260252, 5.921630859375001] - ] - }, - { - "id": 3, - "name": "Zone C", - "tooltip": "tooltip", - "bbox": [ - [45.933960441921585, 7.053222656250001], - [44.47299117260252, 5.921630859375001] - ] - }, - { - "id": 4, - "name": "Zone D", - "tooltip": "tooltip", - "bbox": [ - [45.933960441921585, 7.053222656250001], - [44.47299117260252, 5.921630859375001] - ] - }, - { - "id": 5, - "name": "Zone E", - "tooltip": "tooltip", - "bbox": [ - [45.933960441921585, 7.053222656250001], - [44.47299117260252, 5.921630859375001] - ] - }, - { - "id": 6, - "name": "Zone F", - "tooltip": "tooltip", - "bbox": [ - [45.933960441921585, 7.053222656250001], - [44.47299117260252, 5.921630859375001] - ] - } -] diff --git a/front-end/src/data/evenements_remarquables.json b/front-end/src/data/evenements_remarquables.json deleted file mode 100644 index e6e874f..0000000 --- a/front-end/src/data/evenements_remarquables.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "type": "FeatureCollection", - "features": [ - { - "type": "Feature", - "id": "Evenements_remarquables.21", - "bbox": [ - 6.410661006147566, 44.64683876379312, 6.410661006147566, - 44.64683876379312 - ], - "geometry": { - "type": "Point", - "coordinates": [6.410661006, 44.646838764] - }, - "properties": { - "uuid": 21, - "name": "Nom de l'événement", - "comments": "Fin d’été 2015. Date exacte non précisée. Disparition à la fin de l’été 2004 d’un névé permanent (avec plus de 3 mètres d’épaisseur de glace encore quelques années plus tôt) et création d’un lacquet. Suite à la création de cette marre en 2004 (10 mètres de diamètre et 2 mètres de profondeur), j’ai suivi pratiquement chaque année son évolution. Le lac a grossi un peu plus chaque année jusqu’à son maximum en 2015 (35 mètres de long sur 15 de large pour 5 mètres de profondeur estimée). Toutefois il y a eu plusieurs années ou le lac n’est pas apparu ( 2013, 2014 et 2016) suite à des cumuls de neige apportés par des avalanches de printemps très importantes (névé non fondu) et (ou) des étés moins chaud.", - "event_date": "2015-09-01", - "source": "source", - "category": 13, - "main_picture": { - "id": 1, - "uuid": "1", - "legend": "legend", - "media_file": "https://websig.ecrins-parcnational.fr/index.php/view/media/getMedia?repository=rgalt&project=rgalt&path=media/nouveau-lac-dent-02.jpg", - "media_type": "image", - "thumbnails": { - "small": "https://websig.ecrins-parcnational.fr/index.php/view/media/getMedia?repository=rgalt&project=rgalt&path=media/nouveau-lac-dent-02.jpg", - "medium": "https://websig.ecrins-parcnational.fr/index.php/view/media/getMedia?repository=rgalt&project=rgalt&path=media/nouveau-lac-dent-02.jpg", - "large": "https://websig.ecrins-parcnational.fr/index.php/view/media/getMedia?repository=rgalt&project=rgalt&path=media/nouveau-lac-dent-02.jpg" - } - }, - "medias": [ - { - "id": 1, - "uuid": "1", - "legend": "legend", - "media_file": "https://websig.ecrins-parcnational.fr/index.php/view/media/getMedia?repository=rgalt&project=rgalt&path=media/nouveau-lac-dent-02.jpg", - "media_type": "image", - "thumbnails": { - "small": "https://websig.ecrins-parcnational.fr/index.php/view/media/getMedia?repository=rgalt&project=rgalt&path=media/nouveau-lac-dent-02.jpg", - "medium": "https://websig.ecrins-parcnational.fr/index.php/view/media/getMedia?repository=rgalt&project=rgalt&path=media/nouveau-lac-dent-02.jpg", - "large": "https://websig.ecrins-parcnational.fr/index.php/view/media/getMedia?repository=rgalt&project=rgalt&path=media/nouveau-lac-dent-02.jpg" - } - } - ] - } - } - ] -} diff --git a/front-end/src/data/types.json b/front-end/src/data/types.json deleted file mode 100644 index 5d2ca9e..0000000 --- a/front-end/src/data/types.json +++ /dev/null @@ -1,94 +0,0 @@ -[ - { - "id": 1, - "label": "Avalanche", - "description": "description", - "pictogram": "question_mark", - "children": [ - { - "id": 13, - "label": "Chablis", - "pictogram": "question_mark", - "description": "description", - "children": [] - } - ] - }, - { - "id": 2, - "label": "Chablis", - "pictogram": "question_mark", - "description": "description", - "children": [] - }, - { - "id": 3, - "label": "Chute de blocs", - "pictogram": "question_mark", - "description": "description", - "children": [] - }, - { - "id": 4, - "label": "Crue torrentielle", - "pictogram": "question_mark", - "description": "description", - "children": [] - }, - { - "id": 5, - "label": "Incendie", - "pictogram": "question_mark", - "description": "description", - "children": [] - }, - { - "id": 6, - "label": "Inondation", - "pictogram": "question_mark", - "description": "description", - "children": [] - }, - { - "id": 7, - "label": "Lave torrentielle", - "pictogram": "question_mark", - "description": "description", - "children": [] - }, - { - "id": 8, - "label": "Ravinement", - "pictogram": "question_mark", - "description": "description", - "children": [] - }, - { - "id": 9, - "label": "Volis", - "pictogram": "question_mark", - "description": "description", - "children": [] - }, - { - "id": 10, - "label": "Apparition d'un nouveau lac", - "pictogram": "question_mark", - "description": "description", - "children": [] - }, - { - "id": 11, - "label": "Glissement de terrain", - "pictogram": "question_mark", - "description": "description", - "children": [] - }, - { - "id": 12, - "label": "Phénomène glaciaire", - "pictogram": "question_mark", - "description": "description", - "children": [] - } -] diff --git a/front-end/src/env.d.ts b/front-end/src/env.d.ts new file mode 100644 index 0000000..f27c3a9 --- /dev/null +++ b/front-end/src/env.d.ts @@ -0,0 +1,27 @@ +// Define the type of the environment variables. +declare interface Env { + readonly NODE_ENV: string; + // Replace the following with your own environment variables. + // Example: NGX_VERSION: string; + [key: string]: any; +} + +// Choose how to access the environment variables. +// Remove the unused options. + +// 1. Use import.meta.env.YOUR_ENV_VAR in your code. (conventional) +declare interface ImportMeta { + readonly env: Env; +} + +// 2. Use _NGX_ENV_.YOUR_ENV_VAR in your code. (customizable) +// You can modify the name of the variable in angular.json. +// ngxEnv: { +// define: '_NGX_ENV_', +// } +declare const _NGX_ENV_: Env; + +// 3. Use process.env.YOUR_ENV_VAR in your code. (deprecated) +declare namespace NodeJS { + export interface ProcessEnv extends Env {} +} diff --git a/front-end/src/environments/environment.development.ts b/front-end/src/environments/environment.development.ts index 5544dbe..6d8c3b3 100644 --- a/front-end/src/environments/environment.development.ts +++ b/front-end/src/environments/environment.development.ts @@ -1,3 +1,12 @@ export const environment = { - apiUrl: '', + baseMaps: { + mainMap: { + url: 'https://data.geopf.fr/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=GEOGRAPHICALGRIDSYSTEMS.PLANIGNV2&STYLE=normal&FORMAT=image/png&TILEMATRIXSET=PM&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}', + attribution: 'IGN', + }, + satellitMap: { + url: 'https://data.geopf.fr/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=ORTHOIMAGERY.ORTHOPHOTOS&STYLE=normal&FORMAT=image/jpeg&TILEMATRIXSET=PM&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}', + attribution: 'IGN', + }, + }, }; diff --git a/front-end/src/environments/environment.ts b/front-end/src/environments/environment.ts index 5544dbe..6d8c3b3 100644 --- a/front-end/src/environments/environment.ts +++ b/front-end/src/environments/environment.ts @@ -1,3 +1,12 @@ export const environment = { - apiUrl: '', + baseMaps: { + mainMap: { + url: 'https://data.geopf.fr/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=GEOGRAPHICALGRIDSYSTEMS.PLANIGNV2&STYLE=normal&FORMAT=image/png&TILEMATRIXSET=PM&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}', + attribution: 'IGN', + }, + satellitMap: { + url: 'https://data.geopf.fr/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=ORTHOIMAGERY.ORTHOPHOTOS&STYLE=normal&FORMAT=image/jpeg&TILEMATRIXSET=PM&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}', + attribution: 'IGN', + }, + }, };