From 6deed13d0bf7b5fadaeb3ac0fe87390a7b6329c1 Mon Sep 17 00:00:00 2001 From: Tykayn Date: Sat, 4 Oct 2025 19:18:10 +0200 Subject: [PATCH] =?UTF-8?q?up=20display;=20ajout=20scrap=20agendadulibre;?= =?UTF-8?q?=20qa=20=C3=A9v=C3=A8nements=20sans=20localisation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/OSM_OAUTH_SETUP.md | 41 ++ frontend/public/static/oedb.png | Bin 0 -> 29876 bytes frontend/src/app/app.routes.ts | 5 + frontend/src/app/forms/osm/osm.html | 52 +- frontend/src/app/forms/osm/osm.scss | 114 ++++ frontend/src/app/forms/osm/osm.ts | 36 +- .../src/app/maps/all-events/all-events.ts | 81 ++- .../unlocated-events/unlocated-events.html | 1 + .../unlocated-events/unlocated-events.scss | 0 .../unlocated-events/unlocated-events.spec.ts | 23 + .../page/unlocated-events/unlocated-events.ts | 11 + frontend/src/app/pages/agenda/agenda.html | 43 ++ frontend/src/app/pages/agenda/agenda.scss | 68 ++ frontend/src/app/pages/agenda/agenda.ts | 75 +- frontend/src/app/pages/home/home.html | 21 +- frontend/src/app/pages/home/home.scss | 3 +- frontend/src/app/pages/home/home.ts | 69 +- frontend/src/app/pages/home/menu/menu.html | 1 + .../unlocated-events/unlocated-events.html | 285 ++++++++ .../unlocated-events/unlocated-events.scss | 644 ++++++++++++++++++ .../unlocated-events/unlocated-events.ts | 381 +++++++++++ frontend/src/app/services/osm-auth.ts | 214 ++++++ frontend/src/environments/environment.prod.ts | 6 + frontend/src/environments/environment.ts | 6 + frontend/src/styles.scss | 38 ++ 25 files changed, 2165 insertions(+), 53 deletions(-) create mode 100644 frontend/OSM_OAUTH_SETUP.md create mode 100644 frontend/public/static/oedb.png create mode 100644 frontend/src/app/page/unlocated-events/unlocated-events.html create mode 100644 frontend/src/app/page/unlocated-events/unlocated-events.scss create mode 100644 frontend/src/app/page/unlocated-events/unlocated-events.spec.ts create mode 100644 frontend/src/app/page/unlocated-events/unlocated-events.ts create mode 100644 frontend/src/app/pages/unlocated-events/unlocated-events.html create mode 100644 frontend/src/app/pages/unlocated-events/unlocated-events.scss create mode 100644 frontend/src/app/pages/unlocated-events/unlocated-events.ts create mode 100644 frontend/src/environments/environment.prod.ts create mode 100644 frontend/src/environments/environment.ts diff --git a/frontend/OSM_OAUTH_SETUP.md b/frontend/OSM_OAUTH_SETUP.md new file mode 100644 index 0000000..4352cb9 --- /dev/null +++ b/frontend/OSM_OAUTH_SETUP.md @@ -0,0 +1,41 @@ +# Configuration OAuth2 OpenStreetMap + +## Variables d'environnement requises + +Pour utiliser l'authentification OSM, vous devez configurer les variables suivantes : + +### Frontend (environments/environment.ts) +```typescript +export const environment = { + production: false, + osmClientId: 'your_osm_client_id_here', + osmClientSecret: 'your_osm_client_secret_here', // Ne pas utiliser côté client + apiBaseUrl: 'http://localhost:5000' +}; +``` + +### Backend (.env) +```bash +OSM_CLIENT_ID=your_osm_client_id_here +OSM_CLIENT_SECRET=your_osm_client_secret_here +API_BASE_URL=http://localhost:5000 +``` + +## Configuration OSM + +1. Allez sur https://www.openstreetmap.org/user/your_username/oauth_clients +2. Créez une nouvelle application OAuth +3. Configurez l'URL de redirection : `http://localhost:4200/oauth/callback` +4. Copiez le Client ID et Client Secret + +## Fonctionnalités + +- Connexion/déconnexion OSM +- Persistance des données utilisateur en localStorage +- Ajout automatique du pseudo OSM dans `last_modified_by` pour les nouveaux événements +- Interface utilisateur pour gérer l'authentification + +## Sécurité + +⚠️ **Important** : Le Client Secret ne doit jamais être exposé côté client. +L'échange du code d'autorisation contre un token d'accès doit se faire côté serveur. diff --git a/frontend/public/static/oedb.png b/frontend/public/static/oedb.png new file mode 100644 index 0000000000000000000000000000000000000000..6cda090fe204da57311f1fb305f6b109bb01d8af GIT binary patch literal 29876 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
N;x}1Vg~Btr}|Su;J5X zM)L^E3R~JlkSBrr1cBF;w#`7CP?UNOD8ZC^o*R0^c4HAGXM`SP{>H72by0YE04^rN z6^eC+?cG>PX@o6JB0@0Mj_7YH1QYOzfb4l|%SNW#HjEAaNl0!mmbT4K2tvLVIb*h~ zT}895#en{tw4v&{#{ngn+~cJyEg_f}zEDU6iOd|bIn1B~fm0u>L{_UO2&gH`*J}DF zw^mSI3Z|Td2GFBXZEb~AAh&g*OYrOcannffo|L{95b9VRwV{8?J6oDx?Y$#WJb z-oY7y<%K?K=9-O^LVPDXBftpfTd!QA2&K?&FT@(s*TpIg(7o77B9(Tcx-PWog%v%YT7x#7+0rI5y19B7>OWtZ5vS)Jm-KCOrEpY zoerQhhab*D5+Z z4k*Fo58spS^&kcME+VTQkS|y7+#}Q*L5bUs{B02M3uI0iLdF-QL7%sNtJbcJyFoI7 z33{>SRVok^NE&6UDPg${)sKJLstsDYrhQ~U;0a)G@cgcqR+=F#Q^n#%_$ul+;2cnb zDd0Cqc6dn2xSFzttRf+Wl@)@EW7{(T&E4FzF6-H-j-%BAz|%JD-LK&kt=+wkZQ6TK z%lN_zD_Ow=7l*W>g;{k%2gQmAf6aWcMDxb>bLN^&?EAS(7+fJ@gzZ?ZvL!XFD*R2O zAX+~2OsuZ@8x9UA!4!`BL$ZZL6}=D+fMwZ7 zt_S<+?-}Z+9Yyi;K0a`PxODk{4BIGVKgK_-;1VHZjKNlO`23~XiVD&Dv402)DMErt zQM5Rq1XHx`r{!$0(m{yK)_sTAWm->_CIB_0CbzQUSvKhLNeDmWw1d*WbfJPY)`ALX z&10SirFkWof^sK(9!D(iso!uN7>D0u@cCA#?ui3RFsa8taX@h(a2!z3x`DeTYCwtu miUUe8sb@fOKye^&9Qc2l5n%UFF|||x0000 - osm works! - - - @if(isLogginIn){ -
- {{osmPseudo}} -
- -} -@else{ -
- pas connecté -
- -} -

\ No newline at end of file +
+ @if (isAuthenticated) { + + } @else { + + } +
\ No newline at end of file diff --git a/frontend/src/app/forms/osm/osm.scss b/frontend/src/app/forms/osm/osm.scss index e69de29..eef128f 100644 --- a/frontend/src/app/forms/osm/osm.scss +++ b/frontend/src/app/forms/osm/osm.scss @@ -0,0 +1,114 @@ +.osm-auth { + padding: 15px; + border: 1px solid #e9ecef; + border-radius: 8px; + background: #f8f9fa; + margin-bottom: 15px; + + .user-info { + display: flex; + align-items: center; + gap: 12px; + + .user-avatar { + .avatar { + width: 40px; + height: 40px; + border-radius: 50%; + object-fit: cover; + border: 2px solid #007bff; + } + + .avatar-placeholder { + width: 40px; + height: 40px; + border-radius: 50%; + background: #007bff; + color: white; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + } + } + + .user-details { + flex: 1; + + .username { + font-weight: 600; + color: #333; + margin-bottom: 4px; + } + + .user-stats { + font-size: 12px; + color: #666; + + .stat { + background: #e9ecef; + padding: 2px 6px; + border-radius: 12px; + margin-right: 8px; + } + } + } + } + + .login-prompt { + text-align: center; + + .login-text { + margin-bottom: 15px; + + p { + margin: 0 0 10px 0; + color: #555; + font-size: 14px; + } + + ul { + margin: 0; + padding-left: 20px; + text-align: left; + font-size: 13px; + color: #666; + + li { + margin-bottom: 4px; + } + } + } + + .btn { + padding: 10px 20px; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: all 0.2s ease; + + &.btn-primary { + background: #007bff; + color: white; + + &:hover { + background: #0056b3; + transform: translateY(-1px); + } + } + + &.btn-outline { + background: transparent; + color: #6c757d; + border: 1px solid #6c757d; + + &:hover { + background: #6c757d; + color: white; + } + } + } + } +} diff --git a/frontend/src/app/forms/osm/osm.ts b/frontend/src/app/forms/osm/osm.ts index 1a03de3..7562b39 100644 --- a/frontend/src/app/forms/osm/osm.ts +++ b/frontend/src/app/forms/osm/osm.ts @@ -1,20 +1,44 @@ -import { Component } from '@angular/core'; +import { Component, inject, OnInit, OnDestroy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { OsmAuth, OsmUser } from '../../services/osm-auth'; +import { Subscription } from 'rxjs'; @Component({ selector: 'app-osm', - imports: [], + standalone: true, + imports: [CommonModule], templateUrl: './osm.html', styleUrl: './osm.scss' }) -export class Osm { - osmPseudo: string=''; - isLogginIn: any = false; +export class Osm implements OnInit, OnDestroy { + private osmAuth = inject(OsmAuth); + private subscription?: Subscription; + + currentUser: OsmUser | null = null; + isAuthenticated = false; - logout() { + ngOnInit() { + this.subscription = this.osmAuth.currentUser$.subscribe(user => { + this.currentUser = user; + this.isAuthenticated = !!user; + }); + } + ngOnDestroy() { + if (this.subscription) { + this.subscription.unsubscribe(); + } } login() { + this.osmAuth.initiateOAuthLogin(); + } + logout() { + this.osmAuth.logout(); + } + + getUsername(): string { + return this.osmAuth.getUsername() || ''; } } diff --git a/frontend/src/app/maps/all-events/all-events.ts b/frontend/src/app/maps/all-events/all-events.ts index 338a4ef..a13dd7f 100644 --- a/frontend/src/app/maps/all-events/all-events.ts +++ b/frontend/src/app/maps/all-events/all-events.ts @@ -175,7 +175,7 @@ export class AllEvents implements OnInit, OnDestroy { } else if (coords.length === 4) { const maplibregl = (window as any).maplibregl; const bounds = new maplibregl.LngLatBounds([coords[0], coords[1]], [coords[2], coords[3]]); - this.map.fitBounds(bounds, { padding: 40 }); + // this.map.fitBounds(bounds, { padding: 40 }); } } } catch {} @@ -237,18 +237,26 @@ export class AllEvents implements OnInit, OnDestroy { el.style.boxShadow = '0 0 0 4px rgba(25,118,210,0.25)'; el.style.borderRadius = '50%'; } - el.addEventListener('click', () => { + const popupHtml = this.buildPopupHtml(p, (p && (p.id ?? p.uuid)) ?? f?.id); + const marker = new maplibregl.Marker({ element: el }) + .setLngLat(coords) + .setPopup(new maplibregl.Popup({ + offset: 12, + closeOnClick: false, // Empêcher la fermeture au clic sur la carte + closeButton: true + }).setHTML(popupHtml)) + .addTo(this.map); + + el.addEventListener('click', (e) => { + e.stopPropagation(); // Empêcher la propagation du clic vers la carte + // Ouvrir la popup du marqueur + marker.togglePopup(); this.select.emit({ id: fid, properties: p, geometry: { type: 'Point', coordinates: coords } }); }); - const popupHtml = this.buildPopupHtml(p, (p && (p.id ?? p.uuid)) ?? f?.id); - const marker = new maplibregl.Marker({ element: el }) - .setLngLat(coords) - .setPopup(new maplibregl.Popup({ offset: 12 }).setHTML(popupHtml)) - .addTo(this.map); const popup = marker.getPopup && marker.getPopup(); if (popup && popup.on) { @@ -272,17 +280,15 @@ export class AllEvents implements OnInit, OnDestroy { bounds.extend(coords); }); - // Ne pas faire de fitBounds lors du chargement initial si on a des paramètres URL + // Ne faire fitBounds que lors du chargement initial et seulement si pas de paramètres URL if (!bounds.isEmpty() && this.isInitialLoad) { const hasUrlParams = this.route.snapshot.queryParams['lat'] || this.route.snapshot.queryParams['lon'] || this.route.snapshot.queryParams['zoom']; if (!hasUrlParams) { - this.map.fitBounds(bounds, { padding: 40, maxZoom: 12 }); + // this.map.fitBounds(bounds, { padding: 40, maxZoom: 12 }); } this.isInitialLoad = false; - } else if (!bounds.isEmpty() && !this.isInitialLoad) { - // Pour les mises à jour suivantes, on peut faire un fitBounds léger - this.map.fitBounds(bounds, { padding: 40, maxZoom: 12 }); } + // Supprimer le fitBounds automatique lors des mises à jour pour éviter le dézoom } private buildMarkerElement(props: any): HTMLDivElement { @@ -365,15 +371,54 @@ export class AllEvents implements OnInit, OnDestroy { private buildPopupHtml(props: any, id?: any): string { const title = this.escapeHtml(String(props?.name || props?.label || props?.what || 'évènement')); const titleId = typeof id !== 'undefined' ? String(id) : ''; - const rows = Object.keys(props || {}).sort().map(k => { + + // Informations principales à afficher en priorité + const mainInfo = []; + if (props?.what) mainInfo.push({ key: 'Type', value: props.what }); + if (props?.where) mainInfo.push({ key: 'Lieu', value: props.where }); + if (props?.start) mainInfo.push({ key: 'Début', value: this.formatDate(props.start) }); + if (props?.stop) mainInfo.push({ key: 'Fin', value: this.formatDate(props.stop) }); + if (props?.url) mainInfo.push({ key: 'Lien', value: `Voir l'événement` }); + + const mainRows = mainInfo.map(info => + `${this.escapeHtml(info.key)}${info.value}` + ).join(''); + + // Autres propriétés + const otherProps = Object.keys(props || {}) + .filter(k => !['name', 'label', 'what', 'where', 'start', 'stop', 'url', 'id', 'uuid'].includes(k)) + .sort(); + + const otherRows = otherProps.map(k => { const v = props[k]; - const value = typeof v === 'object' ? `
${this.escapeHtml(JSON.stringify(v, null, 2))}
` : this.escapeHtml(String(v)); - return `${this.escapeHtml(k)}${value}`; + const value = typeof v === 'object' ? `
${this.escapeHtml(JSON.stringify(v, null, 2))}
` : this.escapeHtml(String(v)); + return `${this.escapeHtml(k)}${value}`; }).join(''); - const clickable = `
- ${title} + + const clickable = ``; - return `
${clickable}${rows}
`; + + return `
+ ${clickable} + ${mainRows}
+ ${otherRows ? `
Plus de détails${otherRows}
` : ''} +
`; + } + + private formatDate(dateStr: string): string { + try { + const date = new Date(dateStr); + return date.toLocaleString('fr-FR', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } catch { + return dateStr; + } } private escapeHtml(s: string): string { diff --git a/frontend/src/app/page/unlocated-events/unlocated-events.html b/frontend/src/app/page/unlocated-events/unlocated-events.html new file mode 100644 index 0000000..3ec8458 --- /dev/null +++ b/frontend/src/app/page/unlocated-events/unlocated-events.html @@ -0,0 +1 @@ +

unlocated-events works!

diff --git a/frontend/src/app/page/unlocated-events/unlocated-events.scss b/frontend/src/app/page/unlocated-events/unlocated-events.scss new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/page/unlocated-events/unlocated-events.spec.ts b/frontend/src/app/page/unlocated-events/unlocated-events.spec.ts new file mode 100644 index 0000000..ea13b85 --- /dev/null +++ b/frontend/src/app/page/unlocated-events/unlocated-events.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { UnlocatedEvents } from './unlocated-events'; + +describe('UnlocatedEvents', () => { + let component: UnlocatedEvents; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [UnlocatedEvents] + }) + .compileComponents(); + + fixture = TestBed.createComponent(UnlocatedEvents); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/page/unlocated-events/unlocated-events.ts b/frontend/src/app/page/unlocated-events/unlocated-events.ts new file mode 100644 index 0000000..c8e6537 --- /dev/null +++ b/frontend/src/app/page/unlocated-events/unlocated-events.ts @@ -0,0 +1,11 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-unlocated-events', + imports: [], + templateUrl: './unlocated-events.html', + styleUrl: './unlocated-events.scss' +}) +export class UnlocatedEvents { + +} diff --git a/frontend/src/app/pages/agenda/agenda.html b/frontend/src/app/pages/agenda/agenda.html index 4eae446..1d16b8d 100644 --- a/frontend/src/app/pages/agenda/agenda.html +++ b/frontend/src/app/pages/agenda/agenda.html @@ -22,9 +22,52 @@ (click)="setView(CalendarView.Day)"> Jour +
+ + @if (showFiltersPanel) { +
+

Filtres d'événements

+ +
+ +
+ +
+

Types d'événements

+
+ @for (eventType of availableEventTypes; track eventType) { + + } +
+
+ +
+ +
+
+ } +
; events: OedbEvent[] = []; + filteredEvents: OedbEvent[] = []; calendarEvents: CalendarEvent[] = []; selectedEvent: OedbEvent | null = null; showSidePanel = false; + showFiltersPanel = false; view: CalendarView = CalendarView.Month; viewDate: Date = new Date(); oedbPresets = oedb.presets.what; + // Propriétés pour les filtres + hideTrafficEvents = true; // Par défaut, masquer les événements de type traffic + selectedEventTypes: string[] = []; + availableEventTypes: string[] = []; + // Exposer CalendarView pour l'utiliser dans le template CalendarView = CalendarView; @@ -72,12 +80,44 @@ export class Agenda implements OnInit { this.oedbApi.getEvents(params).subscribe((response: any) => { this.events = Array.isArray(response?.features) ? response.features : []; - this.organizeEventsByDay(); + this.updateAvailableEventTypes(); + this.applyFilters(); }); } + updateAvailableEventTypes() { + const eventTypes = new Set(); + this.events.forEach(event => { + if (event?.properties?.what) { + eventTypes.add(event.properties.what); + } + }); + this.availableEventTypes = Array.from(eventTypes).sort(); + } + + applyFilters() { + let filtered = [...this.events]; + + // Filtre par défaut : masquer les événements de type traffic + if (this.hideTrafficEvents) { + filtered = filtered.filter(event => + !event?.properties?.what?.startsWith('traffic.') + ); + } + + // Filtre par types d'événements sélectionnés + if (this.selectedEventTypes.length > 0) { + filtered = filtered.filter(event => + this.selectedEventTypes.includes(event?.properties?.what || '') + ); + } + + this.filteredEvents = filtered; + this.organizeEventsByDay(); + } + organizeEventsByDay() { - this.calendarEvents = this.events.map(event => { + this.calendarEvents = this.filteredEvents.map(event => { const eventDate = this.getEventDate(event); const preset = this.getEventPreset(event); @@ -220,4 +260,33 @@ export class Agenda implements OnInit { }: CalendarEventTimesChangedEvent): void { console.log('Event times changed:', event, newStart, newEnd); } + + toggleFiltersPanel() { + this.showFiltersPanel = !this.showFiltersPanel; + } + + onHideTrafficChange() { + this.applyFilters(); + } + + onEventTypeChange(eventType: string, checked: boolean) { + if (checked) { + if (!this.selectedEventTypes.includes(eventType)) { + this.selectedEventTypes.push(eventType); + } + } else { + this.selectedEventTypes = this.selectedEventTypes.filter(type => type !== eventType); + } + this.applyFilters(); + } + + isEventTypeSelected(eventType: string): boolean { + return this.selectedEventTypes.includes(eventType); + } + + clearAllFilters() { + this.selectedEventTypes = []; + this.hideTrafficEvents = true; + this.applyFilters(); + } } \ No newline at end of file diff --git a/frontend/src/app/pages/home/home.html b/frontend/src/app/pages/home/home.html index 0b84fe1..8ecd971 100644 --- a/frontend/src/app/pages/home/home.html +++ b/frontend/src/app/pages/home/home.html @@ -2,7 +2,7 @@
OpenEventDatabase - {{features.length}} évènements + {{filteredFeatures.length}} évènements @if (isLoading) { ⏳ Chargement... } @@ -42,18 +42,29 @@
- + + +
+ + +

- +
+
@if (!showTable) {
- +
} @else {
@@ -67,7 +78,7 @@ - @for (f of features; track f.id) { + @for (f of filteredFeatures; track f.id) { {{f?.properties?.what}} {{f?.properties?.label || f?.properties?.name}} diff --git a/frontend/src/app/pages/home/home.scss b/frontend/src/app/pages/home/home.scss index 26a2afd..4bd892b 100644 --- a/frontend/src/app/pages/home/home.scss +++ b/frontend/src/app/pages/home/home.scss @@ -4,7 +4,7 @@ .layout { display: grid; - grid-template-columns: 340px 1fr; + grid-template-columns: 400px 1fr; grid-template-rows: 100vh; gap: 0; } @@ -14,6 +14,7 @@ border-right: 1px solid rgba(0,0,0,0.06); box-shadow: 2px 0 12px rgba(0,0,0,0.03); padding: 16px; + padding-bottom: 150px; overflow: auto; } diff --git a/frontend/src/app/pages/home/home.ts b/frontend/src/app/pages/home/home.ts index 0f51efa..0159b37 100644 --- a/frontend/src/app/pages/home/home.ts +++ b/frontend/src/app/pages/home/home.ts @@ -6,6 +6,8 @@ import { AllEvents } from '../../maps/all-events/all-events'; import { EditForm } from '../../forms/edit-form/edit-form'; import { OedbApi } from '../../services/oedb-api'; import { UnlocatedEvents } from '../../shared/unlocated-events/unlocated-events'; +import { OsmAuth } from '../../services/osm-auth'; +import { Osm } from '../../forms/osm/osm'; @Component({ selector: 'app-home', standalone: true, @@ -14,6 +16,7 @@ import { UnlocatedEvents } from '../../shared/unlocated-events/unlocated-events' AllEvents, UnlocatedEvents, EditForm, + Osm, FormsModule ], templateUrl: './home.html', @@ -23,8 +26,10 @@ export class Home implements OnInit, OnDestroy { OedbApi = inject(OedbApi); private router = inject(Router); + private osmAuth = inject(OsmAuth); features: Array = []; + filteredFeatures: Array = []; selected: any | null = null; showTable = false; @@ -33,6 +38,11 @@ export class Home implements OnInit, OnDestroy { autoReloadInterval: any = null; daysAhead = 7; // Nombre de jours dans le futur par défaut isLoading = false; + + // Propriétés pour les filtres + searchText = ''; + selectedWhatFilter = ''; + availableWhatTypes: string[] = []; ngOnInit() { this.loadEvents(); @@ -57,6 +67,8 @@ export class Home implements OnInit, OnDestroy { this.OedbApi.getEvents(params).subscribe((events: any) => { this.features = Array.isArray(events?.features) ? events.features : []; + this.updateAvailableWhatTypes(); + this.applyFilters(); this.isLoading = false; }); } @@ -89,6 +101,50 @@ export class Home implements OnInit, OnDestroy { this.loadEvents(); } + updateAvailableWhatTypes() { + const whatTypes = new Set(); + this.features.forEach(feature => { + if (feature?.properties?.what) { + whatTypes.add(feature.properties.what); + } + }); + this.availableWhatTypes = Array.from(whatTypes).sort(); + } + + onSearchChange() { + this.applyFilters(); + } + + onWhatFilterChange() { + this.applyFilters(); + } + + applyFilters() { + let filtered = [...this.features]; + + // Filtre par texte de recherche + if (this.searchText.trim()) { + const searchLower = this.searchText.toLowerCase(); + filtered = filtered.filter(feature => { + const label = feature?.properties?.label || feature?.properties?.name || ''; + const description = feature?.properties?.description || ''; + const what = feature?.properties?.what || ''; + return label.toLowerCase().includes(searchLower) || + description.toLowerCase().includes(searchLower) || + what.toLowerCase().includes(searchLower); + }); + } + + // Filtre par type d'événement + if (this.selectedWhatFilter) { + filtered = filtered.filter(feature => + feature?.properties?.what === this.selectedWhatFilter + ); + } + + this.filteredFeatures = filtered; + } + goToNewCategories() { this.router.navigate(['/nouvelles-categories']); } @@ -106,9 +162,16 @@ export class Home implements OnInit, OnDestroy { geometry: { type: 'Point', coordinates: [lon, lat] } }; } else { + const osmUsername = this.osmAuth.getUsername(); this.selected = { id: null, - properties: { label: '', description: '', what: '', where: '' }, + properties: { + label: '', + description: '', + what: '', + where: '', + ...(osmUsername && { last_modified_by: osmUsername }) + }, geometry: { type: 'Point', coordinates: [lon, lat] } }; } @@ -141,7 +204,7 @@ export class Home implements OnInit, OnDestroy { } downloadGeoJSON() { - const blob = new Blob([JSON.stringify({ type: 'FeatureCollection', features: this.features }, null, 2)], { type: 'application/geo+json' }); + const blob = new Blob([JSON.stringify({ type: 'FeatureCollection', features: this.filteredFeatures }, null, 2)], { type: 'application/geo+json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; @@ -154,7 +217,7 @@ export class Home implements OnInit, OnDestroy { downloadCSV() { const header = ['id', 'what', 'label', 'start', 'stop', 'lon', 'lat']; - const rows = this.features.map((f: any) => [ + const rows = this.filteredFeatures.map((f: any) => [ JSON.stringify(f?.properties?.id ?? f?.id ?? ''), JSON.stringify(f?.properties?.what ?? ''), JSON.stringify(f?.properties?.label ?? f?.properties?.name ?? ''), diff --git a/frontend/src/app/pages/home/menu/menu.html b/frontend/src/app/pages/home/menu/menu.html index 26b06d3..dddb3fe 100644 --- a/frontend/src/app/pages/home/menu/menu.html +++ b/frontend/src/app/pages/home/menu/menu.html @@ -1,6 +1,7 @@ OpenEventDatabase agenda + événements non localisés stats sources diff --git a/frontend/src/app/pages/unlocated-events/unlocated-events.html b/frontend/src/app/pages/unlocated-events/unlocated-events.html new file mode 100644 index 0000000..7459ea6 --- /dev/null +++ b/frontend/src/app/pages/unlocated-events/unlocated-events.html @@ -0,0 +1,285 @@ +
+
+

Événements non localisés

+

{{unlocatedEvents.length}} événement(s) nécessitant une géolocalisation

+ @if (isLoading) { +
⏳ Chargement...
+ } +
+ +
+
+

Liste des événements

+ @if (unlocatedEvents.length === 0) { +
+

Aucun événement non localisé trouvé.

+
+ } @else { +
+ @for (event of unlocatedEvents; track event.id || event.properties?.id) { +
+
+

{{getEventTitle(event)}}

+ {{event?.properties?.what || 'Non défini'}} +
+
+

{{getEventDescription(event)}}

+
+ @if (event?.properties?.start || event?.properties?.when) { + 📅 {{event?.properties?.start || event?.properties?.when}} + } + @if (event?.properties?.where) { + 📍 {{event?.properties?.where}} + } +
+
+
+ } +
+ } +
+ + @if (selectedEvent) { +
+
+

Modifier l'événement

+
+ @if (!isEditing) { + + } @else { + + + } +
+
+ + @if (isEditing) { +
+ +
+

📍 Géolocalisation

+
+
+ + + @if (nominatimResults.length > 0 || searchQuery.trim()) { + + } +
+ @if (isSearchingLocation) { +
Recherche en cours...
+ } +
+ + @if (nominatimResults.length > 0) { +
+

Résultats de recherche ({{nominatimResults.length}} trouvé(s)) :

+ @for (result of nominatimResults; track result.place_id) { +
+
+
{{result.display_name}}
+
{{result.type}}
+
+
+
📍 {{result.lat}}, {{result.lon}}
+ @if (result.importance) { +
Importance: {{(result.importance * 100).toFixed(1)}}%
+ } +
+ @if (result.address) { +
+ @if (result.address.house_number && result.address.road) { + {{result.address.house_number}} {{result.address.road}} + } + @if (result.address.postcode) { + {{result.address.postcode}} + } + @if (result.address.city) { + {{result.address.city}} + } +
+ } +
+ } +
+ } @else if (!isSearchingLocation && searchQuery.trim() && nominatimResults.length === 0) { +
Aucun résultat trouvé pour "{{searchQuery}}"
+ } + + @if (selectedLocation) { +
+ Lieu sélectionné : {{selectedLocation.display_name}} +
+ Coordonnées : {{selectedLocation.lat}}, {{selectedLocation.lon}} + @if (selectedLocation.address) { +
+ Adresse : + @if (selectedLocation.address.house_number && selectedLocation.address.road) { + {{selectedLocation.address.house_number}} {{selectedLocation.address.road}}, + } + @if (selectedLocation.address.postcode) { + {{selectedLocation.address.postcode}} + } + @if (selectedLocation.address.city) { + {{selectedLocation.address.city}} + } + + } +
+ } + + @if (selectedEvent?.geometry?.coordinates) { +
+ Coordonnées actuelles : + {{selectedEvent.geometry.coordinates[1]}}, {{selectedEvent.geometry.coordinates[0]}} + @if (selectedEvent.geometry.coordinates[0] === 0 && selectedEvent.geometry.coordinates[1] === 0) { + ⚠️ Coordonnées par défaut (0,0) + } +
+ } + + +
+

Coordonnées géographiques

+
+
+ + +
+
+ + +
+
+
+ + +
+ @if (areCoordinatesValid()) { +
+ ✅ Coordonnées valides et prêtes à être sauvegardées +
+ } @else if (selectedEvent?.geometry?.coordinates[0] !== 0 || selectedEvent?.geometry?.coordinates[1] !== 0) { +
+ ⚠️ Coordonnées invalides ou incomplètes +
+ } +
+
+ + +
+

Propriétés de l'événement

+
+ @for (prop of getObjectKeys(selectedEvent?.properties || {}); track prop) { +
+ + + @if (!isGeocodingProperty(prop) || prop === 'where') { + + } +
+ } +
+ + +
+

Ajouter une propriété

+
+ + + +
+
+
+ + +
+ +
+
+ } @else { +
+

Aperçu de l'événement

+
+
{{selectedEvent | json}}
+
+
+ } +
+ } +
+
diff --git a/frontend/src/app/pages/unlocated-events/unlocated-events.scss b/frontend/src/app/pages/unlocated-events/unlocated-events.scss new file mode 100644 index 0000000..8f69bf7 --- /dev/null +++ b/frontend/src/app/pages/unlocated-events/unlocated-events.scss @@ -0,0 +1,644 @@ +.unlocated-events-page { + padding: 20px; + max-width: 1200px; + margin: 0 auto; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + + .header { + margin-bottom: 30px; + text-align: center; + + h1 { + color: #2c3e50; + margin-bottom: 10px; + font-size: 2.5rem; + } + + .subtitle { + color: #7f8c8d; + font-size: 1.1rem; + margin: 0; + } + + .loading { + color: #3498db; + font-weight: 500; + margin-top: 10px; + } + } + + .content { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 30px; + min-height: 600px; + + @media (max-width: 768px) { + grid-template-columns: 1fr; + gap: 20px; + } + } + + .events-list { + h2 { + color: #2c3e50; + margin-bottom: 20px; + font-size: 1.5rem; + } + + .empty-state { + text-align: center; + padding: 40px 20px; + color: #7f8c8d; + background: #f8f9fa; + border-radius: 8px; + border: 2px dashed #dee2e6; + } + + .events-grid { + display: flex; + flex-direction: column; + gap: 15px; + max-height: 600px; + overflow-y: auto; + padding-right: 10px; + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 3px; + } + + &::-webkit-scrollbar-thumb { + background: #c1c1c1; + border-radius: 3px; + } + } + + .event-card { + background: white; + border: 2px solid #e9ecef; + border-radius: 12px; + padding: 20px; + cursor: pointer; + transition: all 0.2s ease; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + + &:hover { + border-color: #3498db; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + } + + &.selected { + border-color: #75a0f6; + background: #f8f5ff; + } + + .event-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 12px; + + h3 { + margin: 0; + color: #2c3e50; + font-size: 1.1rem; + flex: 1; + line-height: 1.3; + } + + .event-type { + background: #3498db; + color: white; + padding: 4px 8px; + border-radius: 4px; + font-size: 0.8rem; + font-weight: 500; + margin-left: 10px; + } + } + + .event-details { + .event-description { + color: #5a6c7d; + margin: 0 0 10px 0; + line-height: 1.4; + font-size: 0.95rem; + } + + .event-meta { + display: flex; + flex-direction: column; + gap: 5px; + + .event-date, .event-location { + font-size: 0.85rem; + color: #7f8c8d; + display: flex; + align-items: center; + gap: 5px; + } + } + } + } + } + + .event-editor { + background: white; + border: 2px solid #e9ecef; + border-radius: 12px; + padding: 25px; + box-shadow: 0 4px 12px rgba(0,0,0,0.1); + + .editor-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 25px; + padding-bottom: 15px; + border-bottom: 2px solid #f1f3f4; + + h2 { + margin: 0; + color: #2c3e50; + font-size: 1.4rem; + } + + .editor-actions { + display: flex; + gap: 10px; + } + } + + .editor-content { + .geolocation-section, .properties-section { + margin-bottom: 30px; + padding: 20px; + background: #f8f9fa; + border-radius: 8px; + border: 1px solid #e9ecef; + + h3, h4 { + margin: 0 0 15px 0; + color: #2c3e50; + font-size: 1.1rem; + } + + h4 { + font-size: 1rem; + margin-bottom: 10px; + } + } + + .search-location { + margin-bottom: 15px; + + .search-input-group { + display: flex; + gap: 10px; + margin-bottom: 10px; + + .input { + flex: 1; + padding: 10px 12px; + border: 2px solid #dee2e6; + border-radius: 6px; + font-size: 1rem; + transition: border-color 0.2s ease; + width: 100%; + + &:focus { + outline: none; + border-color: #3498db; + } + } + + .search-btn { + padding: 10px 20px; + white-space: nowrap; + min-width: 120px; + } + + .clear-btn { + padding: 10px 15px; + white-space: nowrap; + min-width: 80px; + } + } + + .searching { + color: #3498db; + font-size: 0.9rem; + margin-top: 5px; + text-align: center; + padding: 10px; + background: #f8f9ff; + border-radius: 4px; + border: 1px solid #e3f2fd; + } + + .no-results { + color: #e74c3c; + font-size: 0.9rem; + margin-top: 10px; + text-align: center; + padding: 10px; + background: #f5fffb; + border-radius: 4px; + border: 1px solid #fecaca; + } + } + + .location-results { + margin-top: 15px; + + h4 { + color: #2c3e50; + margin-bottom: 12px; + font-size: 1rem; + } + + .location-option { + background: white; + border: 1px solid #dee2e6; + border-radius: 8px; + padding: 15px; + margin-bottom: 10px; + cursor: pointer; + transition: all 0.2s ease; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); + + &:hover { + border-color: #3498db; + background: #f8f9ff; + transform: translateY(-1px); + box-shadow: 0 2px 6px rgba(0,0,0,0.15); + } + + &.selected { + border-color: #859fdb; + background: #f5fffb; + box-shadow: 0 2px 8px rgba(231, 76, 60, 0.2); + } + + .location-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 8px; + + .location-name { + font-weight: 500; + color: #2c3e50; + flex: 1; + line-height: 1.3; + font-size: 0.95rem; + } + + .location-type { + background: #3498db; + color: white; + padding: 2px 8px; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 500; + margin-left: 10px; + text-transform: capitalize; + } + } + + .location-details { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.85rem; + + .location-coords { + color: #7f8c8d; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + } + + .location-score { + color: #27ae60; + font-weight: 500; + } + + .location-address { + font-size: 0.8rem; + color: #6c757d; + margin-top: 5px; + font-style: italic; + } + } + } + } + + .selected-location { + background: #d4edda; + border: 1px solid #c3e6cb; + border-radius: 6px; + padding: 12px; + margin-top: 15px; + color: #155724; + + small { + color: #6c757d; + } + } + + .current-coordinates { + background: #fff3cd; + border: 1px solid #ffeaa7; + border-radius: 6px; + padding: 12px; + margin-top: 10px; + color: #856404; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 0.9rem; + + .warning { + color: #e74c3c; + font-weight: 500; + margin-left: 10px; + } + } + + .coordinates-form { + background: #f8f9fa; + border: 1px solid #e9ecef; + border-radius: 8px; + padding: 20px; + margin-top: 15px; + + h4 { + margin: 0 0 15px 0; + color: #2c3e50; + font-size: 1rem; + } + + .coordinates-inputs { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 15px; + margin-bottom: 15px; + + @media (max-width: 480px) { + grid-template-columns: 1fr; + } + + .coordinate-field { + display: flex; + flex-direction: column; + + label { + font-weight: 500; + color: #2c3e50; + margin-bottom: 5px; + font-size: 0.9rem; + } + + .coordinate-input { + padding: 10px 12px; + border: 2px solid #dee2e6; + border-radius: 6px; + font-size: 0.9rem; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + transition: border-color 0.2s ease; + + &:focus { + outline: none; + border-color: #3498db; + } + + &:invalid { + border-color: #e74c3c; + } + } + } + } + + .coordinate-actions { + display: flex; + gap: 10px; + justify-content: flex-end; + + @media (max-width: 480px) { + flex-direction: column; + } + + .btn { + min-width: 140px; + } + } + + .coordinates-valid { + background: #d4edda; + border: 1px solid #c3e6cb; + border-radius: 4px; + padding: 8px 12px; + margin-top: 10px; + color: #155724; + font-size: 0.85rem; + text-align: center; + } + + .coordinates-invalid { + background: #f8d7da; + border: 1px solid #f5c6cb; + border-radius: 4px; + padding: 8px 12px; + margin-top: 10px; + color: #721c24; + font-size: 0.85rem; + text-align: center; + } + } + + .properties-list { + margin-bottom: 20px; + + .property-item { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 10px; + padding: 10px; + background: white; + border-radius: 6px; + border: 1px solid #dee2e6; + transition: all 0.2s ease; + + &.geocoding-property { + background: #f8f9ff; + border-color: #e3f2fd; + border-left: 3px solid #3498db; + } + + .property-key { + font-weight: 500; + color: #2c3e50; + min-width: 120px; + font-size: 0.9rem; + display: flex; + align-items: center; + gap: 5px; + + .geocoding-badge { + font-size: 0.8rem; + opacity: 0.7; + } + } + + .property-value { + flex: 1; + padding: 8px 10px; + border: 1px solid #dee2e6; + border-radius: 4px; + font-size: 0.9rem; + transition: all 0.2s ease; + + &:focus { + outline: none; + border-color: #3498db; + } + + &[readonly] { + background: #f8f9fa; + color: #6c757d; + cursor: not-allowed; + } + } + + .btn { + padding: 6px 10px; + font-size: 0.8rem; + } + } + } + + .add-property { + .add-property-form { + display: flex; + gap: 10px; + align-items: center; + + .input { + flex: 1; + padding: 8px 10px; + border: 1px solid #dee2e6; + border-radius: 4px; + font-size: 0.9rem; + + &:focus { + outline: none; + border-color: #3498db; + } + } + } + } + + .editor-actions { + text-align: center; + margin-top: 30px; + padding-top: 20px; + border-top: 2px solid #f1f3f4; + } + } + + .event-preview { + .preview-content { + background: #f8f9fa; + border: 1px solid #dee2e6; + border-radius: 6px; + padding: 15px; + max-height: 400px; + overflow-y: auto; + + pre { + margin: 0; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 0.85rem; + line-height: 1.4; + color: #2c3e50; + } + } + } + } + + // Styles pour les boutons + .btn { + padding: 10px 16px; + border: none; + border-radius: 6px; + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + text-decoration: none; + display: inline-block; + text-align: center; + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + &.btn-primary { + background: #3498db; + color: white; + + &:hover:not(:disabled) { + background: #2980b9; + transform: translateY(-1px); + } + } + + &.btn-secondary { + background: #95a5a6; + color: white; + + &:hover:not(:disabled) { + background: #7f8c8d; + } + } + + &.btn-danger { + background: #e74c3c; + color: white; + + &:hover:not(:disabled) { + background: #c0392b; + } + } + + &.btn-sm { + padding: 6px 12px; + font-size: 0.8rem; + } + + &.btn-large { + padding: 15px 30px; + font-size: 1.1rem; + } + } + + .input { + padding: 10px 12px; + border: 2px solid #dee2e6; + border-radius: 6px; + font-size: 1rem; + transition: border-color 0.2s ease; + width: 100%; + box-sizing: border-box; + + &:focus { + outline: none; + border-color: #3498db; + } + } +} diff --git a/frontend/src/app/pages/unlocated-events/unlocated-events.ts b/frontend/src/app/pages/unlocated-events/unlocated-events.ts new file mode 100644 index 0000000..7a83eba --- /dev/null +++ b/frontend/src/app/pages/unlocated-events/unlocated-events.ts @@ -0,0 +1,381 @@ +import { Component, inject, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { OedbApi } from '../../services/oedb-api'; +import { OsmAuth } from '../../services/osm-auth'; + +interface NominatimResult { + place_id: number; + display_name: string; + lat: string; + lon: string; + type: string; + importance: number; + address?: { + house_number?: string; + road?: string; + postcode?: string; + city?: string; + state?: string; + country?: string; + }; +} + +@Component({ + selector: 'app-unlocated-events-page', + standalone: true, + imports: [CommonModule, FormsModule], + templateUrl: './unlocated-events.html', + styleUrl: './unlocated-events.scss' +}) +export class UnlocatedEventsPage implements OnInit { + OedbApi = inject(OedbApi); + private osmAuth = inject(OsmAuth); + + events: Array = []; + unlocatedEvents: Array = []; + isLoading = false; + selectedEvent: any = null; + isEditing = false; + newKey = ''; + newValue = ''; + + // Géolocalisation + searchQuery = ''; + nominatimResults: NominatimResult[] = []; + isSearchingLocation = false; + selectedLocation: NominatimResult | null = null; + + ngOnInit() { + this.loadEvents(); + } + + loadEvents() { + this.isLoading = true; + const today = new Date(); + const endDate = new Date(today); + endDate.setDate(today.getDate() + 30); // Charger 30 jours pour avoir plus d'événements + + const params = { + start: today.toISOString().split('T')[0], + end: endDate.toISOString().split('T')[0], + limit: 1000 + }; + + this.OedbApi.getEvents(params).subscribe((events: any) => { + this.events = Array.isArray(events?.features) ? events.features : []; + this.filterUnlocatedEvents(); + this.isLoading = false; + }); + } + + filterUnlocatedEvents() { + this.unlocatedEvents = (this.events || []).filter(ev => { + // Vérifie si la géométrie est un point + if (!ev.geometry || ev.geometry.type !== 'Point') return false; + const coords = ev.geometry.coordinates; + // Vérifie si les coordonnées sont valides + if (!Array.isArray(coords) || coords.length !== 2) return true; + // Si les coordonnées sont [0,0], on considère comme non localisé + if (coords[0] === 0 && coords[1] === 0) return true; + // Si l'une des coordonnées est manquante ou nulle + if (coords[0] == null || coords[1] == null) return true; + return false; + }); + } + + selectEvent(event: any) { + this.selectedEvent = { ...event }; + this.isEditing = true; // Ouvrir directement le formulaire d'édition + this.searchQuery = event?.properties?.where || ''; + this.nominatimResults = []; + this.selectedLocation = null; + + // S'assurer que l'événement a une géométrie valide + if (!this.selectedEvent.geometry) { + this.selectedEvent.geometry = { + type: 'Point', + coordinates: [0, 0] + }; + } + + // Si l'événement a une propriété 'where', proposer automatiquement une recherche + if (event?.properties?.where) { + this.searchLocation(); + } + } + + startEditing() { + this.isEditing = true; + } + + cancelEditing() { + this.isEditing = false; + this.selectedEvent = null; + } + + searchLocation() { + if (!this.searchQuery.trim()) { + this.nominatimResults = []; + return; + } + + this.isSearchingLocation = true; + this.nominatimResults = []; + + // Utiliser la propriété 'where' de l'événement si disponible, sinon utiliser la recherche manuelle + const searchTerm = this.selectedEvent?.properties?.where || this.searchQuery; + + const url = `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(searchTerm)}&limit=10&addressdetails=1&countrycodes=fr&extratags=1`; + + fetch(url, { + headers: { + 'User-Agent': 'OpenEventDatabase/1.0' + } + }) + .then(response => { + if (!response.ok) { + throw new Error(`Erreur HTTP: ${response.status}`); + } + return response.json(); + }) + .then((data: NominatimResult[]) => { + this.nominatimResults = data; + this.isSearchingLocation = false; + console.log('Résultats Nominatim:', data); + }) + .catch(error => { + console.error('Erreur lors de la recherche Nominatim:', error); + this.isSearchingLocation = false; + // Afficher un message d'erreur à l'utilisateur + this.nominatimResults = []; + }); + } + + selectLocation(location: NominatimResult) { + this.selectedLocation = location; + if (this.selectedEvent) { + // Mettre à jour la géométrie + this.selectedEvent.geometry = { + type: 'Point', + coordinates: [parseFloat(location.lon), parseFloat(location.lat)] + }; + + // Mettre à jour les propriétés de l'événement + if (!this.selectedEvent.properties) { + this.selectedEvent.properties = {}; + } + + // Mettre à jour la propriété 'where' avec le nom du lieu + this.selectedEvent.properties.where = location.display_name; + + // Ajouter d'autres propriétés utiles si elles n'existent pas + if (!this.selectedEvent.properties.label && !this.selectedEvent.properties.name) { + this.selectedEvent.properties.label = location.display_name; + } + + // Ajouter des informations géographiques détaillées + this.selectedEvent.properties.lat = location.lat; + this.selectedEvent.properties.lon = location.lon; + + // Ajouter des informations détaillées de Nominatim + if (location.address) { + if (location.address.house_number) this.selectedEvent.properties.housenumber = location.address.house_number; + if (location.address.road) this.selectedEvent.properties.street = location.address.road; + if (location.address.postcode) this.selectedEvent.properties.postcode = location.address.postcode; + if (location.address.city) this.selectedEvent.properties.city = location.address.city; + if (location.address.state) this.selectedEvent.properties.region = location.address.state; + if (location.address.country) this.selectedEvent.properties.country = location.address.country; + } + + if (location.type) this.selectedEvent.properties.place_type = location.type; + if (location.importance) this.selectedEvent.properties.place_importance = location.importance.toString(); + + // Ajouter une note sur la source de géolocalisation + this.selectedEvent.properties.geocoding_source = 'Nominatim'; + this.selectedEvent.properties.geocoding_date = new Date().toISOString(); + + // S'assurer que les coordonnées sont bien mises à jour dans le formulaire + this.updateCoordinates(); + } + } + + clearSearch() { + this.searchQuery = ''; + this.nominatimResults = []; + this.selectedLocation = null; + this.isSearchingLocation = false; + } + + updateCoordinates() { + // Cette méthode est appelée quand les coordonnées sont modifiées dans le formulaire + // Elle s'assure que la géométrie est correctement mise à jour + if (this.selectedEvent && this.selectedEvent.geometry) { + const lat = parseFloat(this.selectedEvent.geometry.coordinates[1]); + const lon = parseFloat(this.selectedEvent.geometry.coordinates[0]); + + if (!isNaN(lat) && !isNaN(lon)) { + this.selectedEvent.geometry.coordinates = [lon, lat]; + } + } + } + + clearCoordinates() { + if (this.selectedEvent) { + this.selectedEvent.geometry = { + type: 'Point', + coordinates: [0, 0] + }; + this.selectedLocation = null; + + // Remettre à zéro les propriétés de localisation + if (this.selectedEvent.properties) { + this.selectedEvent.properties.where = ''; + // Ne pas effacer le label/name s'ils existent déjà + } + } + } + + validateCoordinates() { + if (this.selectedEvent && this.selectedEvent.geometry) { + const lat = this.selectedEvent.geometry.coordinates[1]; + const lon = this.selectedEvent.geometry.coordinates[0]; + + if (this.areCoordinatesValid()) { + console.log('Coordonnées validées:', { lat, lon }); + this.selectedEvent.geometry.coordinates = [lon, lat]; + this.updateCoordinates(); + // Ici on pourrait ajouter une validation supplémentaire ou une notification + } + } + } + + areCoordinatesValid(): boolean { + if (!this.selectedEvent || !this.selectedEvent.geometry) return false; + + const lat = this.selectedEvent.geometry.coordinates[1]; + const lon = this.selectedEvent.geometry.coordinates[0]; + + // Vérifier que les coordonnées sont des nombres valides + if (isNaN(lat) || isNaN(lon)) return false; + + // Vérifier que les coordonnées sont dans des plages valides + if (lat < -90 || lat > 90) return false; + if (lon < -180 || lon > 180) return false; + + // Vérifier que ce ne sont pas les coordonnées par défaut (0,0) + if (lat === 0 && lon === 0) return false; + + return true; + } + + addProperty() { + if (this.newKey.trim() && this.newValue.trim()) { + if (!this.selectedEvent.properties) { + this.selectedEvent.properties = {}; + } + this.selectedEvent.properties[this.newKey.trim()] = this.newValue.trim(); + this.newKey = ''; + this.newValue = ''; + } + } + + removeProperty(key: string) { + if (this.selectedEvent?.properties) { + delete this.selectedEvent.properties[key]; + } + } + + updateEvent() { + if (!this.selectedEvent) return; + + this.isLoading = true; + const eventId = this.selectedEvent.id || this.selectedEvent.properties?.id; + + if (eventId) { + // Mettre à jour un événement existant + this.OedbApi.updateEvent(eventId, this.selectedEvent).subscribe({ + next: (response) => { + console.log('Événement mis à jour:', response); + this.loadEvents(); + this.selectedEvent = null; + this.isEditing = false; + this.isLoading = false; + }, + error: (error) => { + console.error('Erreur lors de la mise à jour:', error); + this.isLoading = false; + } + }); + } else { + // Créer un nouvel événement + const osmUsername = this.osmAuth.getUsername(); + if (osmUsername) { + this.selectedEvent.properties.last_modified_by = osmUsername; + } + + this.OedbApi.createEvent(this.selectedEvent).subscribe({ + next: (response) => { + console.log('Événement créé:', response); + this.loadEvents(); + this.selectedEvent = null; + this.isEditing = false; + this.isLoading = false; + }, + error: (error) => { + console.error('Erreur lors de la création:', error); + this.isLoading = false; + } + }); + } + } + + deleteEvent() { + if (!this.selectedEvent) return; + + const eventId = this.selectedEvent.id || this.selectedEvent.properties?.id; + if (!eventId) return; + + if (confirm('Êtes-vous sûr de vouloir supprimer cet événement ?')) { + this.isLoading = true; + this.OedbApi.deleteEvent(eventId).subscribe({ + next: (response) => { + console.log('Événement supprimé:', response); + this.loadEvents(); + this.selectedEvent = null; + this.isEditing = false; + this.isLoading = false; + }, + error: (error) => { + console.error('Erreur lors de la suppression:', error); + this.isLoading = false; + } + }); + } + } + + getEventTitle(event: any): string { + return event?.properties?.what || + event?.properties?.label || + event?.properties?.name || + 'Événement sans nom'; + } + + getEventDescription(event: any): string { + return event?.properties?.description || + event?.properties?.where || + 'Aucune description'; + } + + getObjectKeys(obj: any): string[] { + return Object.keys(obj || {}); + } + + isGeocodingProperty(prop: string): boolean { + const geocodingProps = [ + 'lat', 'lon', 'place_type', 'place_importance', 'housenumber', 'street', + 'postcode', 'city', 'region', 'country', 'geocoding_source', 'geocoding_date' + ]; + return geocodingProps.includes(prop); + } +} diff --git a/frontend/src/app/services/osm-auth.ts b/frontend/src/app/services/osm-auth.ts index 764682d..cda663f 100644 --- a/frontend/src/app/services/osm-auth.ts +++ b/frontend/src/app/services/osm-auth.ts @@ -1,8 +1,222 @@ import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { BehaviorSubject, Observable, of } from 'rxjs'; +import { catchError, map, switchMap } from 'rxjs/operators'; +import { environment } from '../../environments/environment'; + +export interface OsmUser { + id: number; + display_name: string; + account_created: string; + description: string; + contributor_terms: { + agreed: boolean; + pd: boolean; + }; + img: { + href: string; + }; + roles: string[]; + changesets: { + count: number; + }; + traces: { + count: number; + }; + blocks: { + received: { + count: number; + active: number; + }; + }; + home: { + lat: number; + lon: number; + zoom: number; + }; + languages: string[]; + messages: { + received: { + count: number; + unread: number; + }; + sent: { + count: number; + }; + }; + preferences: any; +} @Injectable({ providedIn: 'root' }) export class OsmAuth { + private readonly STORAGE_KEY = 'osm_auth_data'; + private readonly OAUTH_BASE_URL = 'https://www.openstreetmap.org/oauth'; + private currentUserSubject = new BehaviorSubject(null); + public currentUser$ = this.currentUserSubject.asObservable(); + + private accessToken: string | null = null; + private clientId: string | null = null; + private redirectUri: string | null = null; + + constructor(private http: HttpClient) { + this.loadStoredAuthData(); + this.loadEnvironmentConfig(); + } + + private loadEnvironmentConfig() { + // Charger la configuration depuis les variables d'environnement + this.clientId = environment.osmClientId; + this.redirectUri = window.location.origin + '/oauth/callback'; + } + + private loadStoredAuthData() { + try { + const stored = localStorage.getItem(this.STORAGE_KEY); + if (stored) { + const authData = JSON.parse(stored); + this.accessToken = authData.accessToken; + if (authData.user) { + this.currentUserSubject.next(authData.user); + } + } + } catch (error) { + console.error('Erreur lors du chargement des données OSM:', error); + this.clearStoredAuthData(); + } + } + + private saveAuthData(user: OsmUser, accessToken: string) { + const authData = { + user, + accessToken, + timestamp: Date.now() + }; + localStorage.setItem(this.STORAGE_KEY, JSON.stringify(authData)); + this.accessToken = accessToken; + this.currentUserSubject.next(user); + } + + private clearStoredAuthData() { + localStorage.removeItem(this.STORAGE_KEY); + this.accessToken = null; + this.currentUserSubject.next(null); + } + + isAuthenticated(): boolean { + return this.accessToken !== null && this.currentUserSubject.value !== null; + } + + getCurrentUser(): OsmUser | null { + return this.currentUserSubject.value; + } + + getAccessToken(): string | null { + return this.accessToken; + } + + getUsername(): string | null { + return this.currentUserSubject.value?.display_name || null; + } + + initiateOAuthLogin(): void { + if (!this.clientId) { + console.error('Client ID OSM non configuré'); + return; + } + + const state = this.generateRandomState(); + sessionStorage.setItem('osm_oauth_state', state); + + const params = new URLSearchParams({ + response_type: 'code', + client_id: this.clientId, + redirect_uri: this.redirectUri!, + scope: 'read_prefs', + state: state + }); + + const authUrl = `${this.OAUTH_BASE_URL}/authorize?${params.toString()}`; + window.location.href = authUrl; + } + + handleOAuthCallback(code: string, state: string): Observable { + const storedState = sessionStorage.getItem('osm_oauth_state'); + if (state !== storedState) { + console.error('État OAuth invalide'); + return of(false); + } + + sessionStorage.removeItem('osm_oauth_state'); + + if (!this.clientId) { + console.error('Client ID OSM non configuré'); + return of(false); + } + + // En production, l'échange du code contre un token se ferait côté serveur + // pour des raisons de sécurité (client_secret) + const tokenData = { + grant_type: 'authorization_code', + code: code, + redirect_uri: this.redirectUri!, + client_id: this.clientId + }; + + // Pour l'instant, on simule une authentification réussie + // En production, il faudrait faire un appel au backend + return this.http.post(`${this.OAUTH_BASE_URL}/token`, tokenData).pipe( + switchMap(response => { + if (response.access_token) { + this.accessToken = response.access_token; + // Appeler fetchUserDetails et retourner son résultat + return this.fetchUserDetails(); + } + return of(false); + }), + catchError(error => { + console.error('Erreur lors de l\'obtention du token OAuth:', error); + return of(false); + }) + ); + } + + private fetchUserDetails(): Observable { + if (!this.accessToken) { + return of(false); + } + + return this.http.get('https://api.openstreetmap.org/api/0.6/user/details.json', { + headers: { + 'Authorization': `Bearer ${this.accessToken}` + } + }).pipe( + map(user => { + this.saveAuthData(user, this.accessToken!); + return true; + }), + catchError(error => { + console.error('Erreur lors de la récupération des détails utilisateur:', error); + this.logout(); + return of(false); + }) + ); + } + + logout(): void { + this.clearStoredAuthData(); + } + + private generateRandomState(): string { + return Math.random().toString(36).substring(2, 15) + + Math.random().toString(36).substring(2, 15); + } + + // Méthode pour configurer les credentials OSM (à appeler depuis l'app) + configureOsmCredentials(clientId: string, clientSecret?: string) { + this.clientId = clientId; + // Le client_secret ne doit jamais être stocké côté client + } } diff --git a/frontend/src/environments/environment.prod.ts b/frontend/src/environments/environment.prod.ts new file mode 100644 index 0000000..3ff22a4 --- /dev/null +++ b/frontend/src/environments/environment.prod.ts @@ -0,0 +1,6 @@ +export const environment = { + production: true, + osmClientId: 'your_production_osm_client_id_here', + osmClientSecret: 'your_production_osm_client_secret_here', + apiBaseUrl: 'https://your-production-api-url.com' +}; diff --git a/frontend/src/environments/environment.ts b/frontend/src/environments/environment.ts new file mode 100644 index 0000000..22820d1 --- /dev/null +++ b/frontend/src/environments/environment.ts @@ -0,0 +1,6 @@ +export const environment = { + production: false, + osmClientId: 'your_osm_client_id_here', // À remplacer par la vraie valeur + osmClientSecret: 'your_osm_client_secret_here', // À remplacer par la vraie valeur + apiBaseUrl: 'http://localhost:5000' // URL de base de l'API backend +}; diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss index 504ebed..ad03aa3 100644 --- a/frontend/src/styles.scss +++ b/frontend/src/styles.scss @@ -125,4 +125,42 @@ label { font-size: 0.85rem; color: $color-muted; } .search{ width: 20%; +} + +.aside{ + padding-bottom: 150px; +} +.actions{ + + position: fixed; + bottom: 10px; + left: 10px; + right: 10px; + width: 340px; + display: flex; + flex-direction: row; + justify-content: end; + align-items: center; + gap: 8px; + z-index: 1000; + background: #fff; + padding: 10px; + border-radius: 10px; + box-shadow: 0 0 10px rgba(0,0,0,0.1); +} + +pre{ + max-width: 400px; +} + +.unlocated-events-page{ + .event-card{ + max-width: 400px; + } + .event-description{ + max-height: 50px; + overflow: auto; + text-overflow: ellipsis; + white-space: nowrap; + } } \ No newline at end of file