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 0000000..6cda090 Binary files /dev/null and b/frontend/public/static/oedb.png differ diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 8886554..792b582 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -2,6 +2,7 @@ import { Routes } from '@angular/router'; import {Home} from './pages/home/home'; import { Agenda } from './pages/agenda/agenda'; import { NouvellesCategories } from './pages/nouvelles-categories/nouvelles-categories'; +import { UnlocatedEventsPage } from './pages/unlocated-events/unlocated-events'; export const routes: Routes = [ { @@ -15,5 +16,9 @@ export const routes: Routes = [ { path : 'nouvelles-categories', component: NouvellesCategories + }, + { + path : 'unlocated-events', + component: UnlocatedEventsPage } ]; diff --git a/frontend/src/app/forms/osm/osm.html b/frontend/src/app/forms/osm/osm.html index 9fedcb8..65d6c25 100644 --- a/frontend/src/app/forms/osm/osm.html +++ b/frontend/src/app/forms/osm/osm.html @@ -1,17 +1,35 @@ -

- osm works! - - - @if(isLogginIn){ -

- {{osmPseudo}} -
- -} -@else{ -
- pas connecté -
- -} -

\ No newline at end of file +
+ @if (isAuthenticated) { +
+
+ @if (currentUser?.img?.href) { + + } @else { +
👤
+ } +
+
+
{{getUsername()}}
+
+ {{currentUser?.changesets?.count || 0}} changesets +
+
+ +
+ } @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 = `
+ ${title}
`; - 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