up display; ajout scrap agendadulibre; qa évènements sans localisation

This commit is contained in:
Tykayn 2025-10-04 19:18:10 +02:00 committed by tykayn
parent 73f18e1d31
commit 6deed13d0b
25 changed files with 2165 additions and 53 deletions

View file

@ -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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View file

@ -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
}
];

View file

@ -1,17 +1,35 @@
<p>
osm works!
@if(isLogginIn){
<div class="pseudo">
{{osmPseudo}}
</div>
<button (click)="logout()">logout</button>
<div class="osm-auth">
@if (isAuthenticated) {
<div class="user-info">
<div class="user-avatar">
@if (currentUser?.img?.href) {
<img [src]="currentUser?.img?.href" [alt]="currentUser?.display_name || 'Utilisateur OSM'" class="avatar">
} @else {
<div class="avatar-placeholder">👤</div>
}
@else{
<div class="pseudo">
pas connecté
</div>
<button (click)="login()">osm login</button>
<div class="user-details">
<div class="username">{{getUsername()}}</div>
<div class="user-stats">
<span class="stat">{{currentUser?.changesets?.count || 0}} changesets</span>
</div>
</div>
<button class="btn btn-sm btn-outline" (click)="logout()">
Déconnexion
</button>
</div>
} @else {
<div class="login-prompt">
<div class="login-text">
<p>Connectez-vous à votre compte OpenStreetMap pour :</p>
<ul>
<li>Ajouter automatiquement votre pseudo aux événements créés</li>
<li>Bénéficier de fonctionnalités avancées</li>
</ul>
</div>
<button class="btn btn-primary" (click)="login()">
🗺️ Se connecter à OSM
</button>
</div>
}
</p>
</div>

View file

@ -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;
}
}
}
}
}

View file

@ -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;
logout() {
currentUser: OsmUser | null = null;
isAuthenticated = false;
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() || '';
}
}

View file

@ -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: `<a href="${this.escapeHtml(props.url)}" target="_blank">Voir l'événement</a>` });
const mainRows = mainInfo.map(info =>
`<tr><td style="font-weight:bold;vertical-align:top;padding:4px 8px;color:#666;">${this.escapeHtml(info.key)}</td><td style="padding:4px 8px;">${info.value}</td></tr>`
).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' ? `<pre>${this.escapeHtml(JSON.stringify(v, null, 2))}</pre>` : this.escapeHtml(String(v));
return `<tr><td style="font-weight:bold;vertical-align:top;padding:2px 6px;">${this.escapeHtml(k)}</td><td style="padding:2px 6px;">${value}</td></tr>`;
const value = typeof v === 'object' ? `<pre style="font-size:11px;margin:0;">${this.escapeHtml(JSON.stringify(v, null, 2))}</pre>` : this.escapeHtml(String(v));
return `<tr><td style="font-weight:bold;vertical-align:top;padding:2px 8px;color:#999;font-size:12px;">${this.escapeHtml(k)}</td><td style="padding:2px 8px;font-size:12px;">${value}</td></tr>`;
}).join('');
const clickable = `<div style="font-weight:700;margin:0 0 6px 0;">
<a href="#" data-feature-id="${this.escapeHtml(titleId)}" style="text-decoration:none;color:#1976d2;">${title}</a>
const clickable = `<div style="font-weight:700;margin:0 0 8px 0;font-size:16px;color:#1976d2;">
<a href="#" data-feature-id="${this.escapeHtml(titleId)}" style="text-decoration:none;color:inherit;">${title}</a>
</div>`;
return `<div style="max-width:320px">${clickable}<table style="border-collapse:collapse;width:100%">${rows}</table></div>`;
return `<div style="max-width:350px;font-family:Arial,sans-serif;">
${clickable}
<table style="border-collapse:collapse;width:100%;margin-bottom:8px;">${mainRows}</table>
${otherRows ? `<details style="margin-top:8px;"><summary style="cursor:pointer;color:#666;font-size:12px;">Plus de détails</summary><table style="border-collapse:collapse;width:100%;margin-top:4px;">${otherRows}</table></details>` : ''}
</div>`;
}
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 {

View file

@ -0,0 +1 @@
<p>unlocated-events works!</p>

View file

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { UnlocatedEvents } from './unlocated-events';
describe('UnlocatedEvents', () => {
let component: UnlocatedEvents;
let fixture: ComponentFixture<UnlocatedEvents>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [UnlocatedEvents]
})
.compileComponents();
fixture = TestBed.createComponent(UnlocatedEvents);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -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 {
}

View file

@ -22,9 +22,52 @@
(click)="setView(CalendarView.Day)">
Jour
</button>
<button
class="btn btn-sm"
(click)="toggleFiltersPanel()">
{{showFiltersPanel ? 'Masquer' : 'Afficher'}} les filtres
</button>
</div>
</div>
<!-- Panneau de filtres latéral -->
@if (showFiltersPanel) {
<div class="filters-panel">
<h3>Filtres d'événements</h3>
<div class="filter-group">
<label>
<input
type="checkbox"
[(ngModel)]="hideTrafficEvents"
(change)="onHideTrafficChange()">
Masquer les événements de circulation
</label>
</div>
<div class="filter-group">
<h4>Types d'événements</h4>
<div class="event-types-list">
@for (eventType of availableEventTypes; track eventType) {
<label class="event-type-item">
<input
type="checkbox"
[checked]="isEventTypeSelected(eventType)"
(change)="onEventTypeChange(eventType, $event.target.checked)">
{{eventType}}
</label>
}
</div>
</div>
<div class="filter-actions">
<button class="btn btn-sm" (click)="clearAllFilters()">
Effacer tous les filtres
</button>
</div>
</div>
}
<div class="agenda-content">
<mwl-calendar-month-view
*ngIf="view === CalendarView.Month"

View file

@ -47,6 +47,74 @@
}
}
// Panneau de filtres
.filters-panel {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
h3 {
margin: 0 0 20px 0;
color: #333;
font-size: 18px;
}
.filter-group {
margin-bottom: 20px;
h4 {
margin: 0 0 10px 0;
color: #555;
font-size: 14px;
font-weight: 600;
}
label {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
cursor: pointer;
font-size: 14px;
input[type="checkbox"] {
margin: 0;
}
}
}
.event-types-list {
max-height: 200px;
overflow-y: auto;
border: 1px solid #e9ecef;
border-radius: 4px;
padding: 10px;
background: white;
.event-type-item {
display: block;
padding: 4px 0;
border-bottom: 1px solid #f1f5f9;
&:last-child {
border-bottom: none;
}
&:hover {
background: #f8f9fa;
}
}
}
.filter-actions {
border-top: 1px solid #e9ecef;
padding-top: 15px;
text-align: center;
}
}
.agenda-content {
margin-bottom: 20px;

View file

@ -1,5 +1,6 @@
import { Component, inject, OnInit, ViewChild, TemplateRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { OedbApi } from '../../services/oedb-api';
import { EditForm } from '../../forms/edit-form/edit-form';
import { CalendarModule, CalendarView, CalendarEvent } from 'angular-calendar';
@ -32,7 +33,7 @@ interface DayEvents {
@Component({
selector: 'app-agenda',
standalone: true,
imports: [CommonModule, EditForm, CalendarModule],
imports: [CommonModule, FormsModule, EditForm, CalendarModule],
templateUrl: './agenda.html',
styleUrl: './agenda.scss'
})
@ -42,13 +43,20 @@ export class Agenda implements OnInit {
@ViewChild('eventTitleTemplate', { static: true }) eventTitleTemplate!: TemplateRef<any>;
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<string>();
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();
}
}

View file

@ -2,7 +2,7 @@
<div class="aside">
<div class="toolbar">
<strong>OpenEventDatabase</strong>
<span class="muted">{{features.length}} évènements</span>
<span class="muted">{{filteredFeatures.length}} évènements</span>
@if (isLoading) {
<span class="loading">⏳ Chargement...</span>
}
@ -42,18 +42,29 @@
<div class="filters">
<label>Filtre rapide</label>
<input class="input" type="text" placeholder="Rechercher...">
<input class="input" type="text" placeholder="Rechercher..." [(ngModel)]="searchText" (ngModelChange)="onSearchChange()">
<div class="control-group">
<label>Filtrer par type d'événement</label>
<select class="input" [(ngModel)]="selectedWhatFilter" (ngModelChange)="onWhatFilterChange()">
<option value="">Tous les types</option>
@for (whatType of availableWhatTypes; track whatType) {
<option [value]="whatType">{{whatType}}</option>
}
</select>
</div>
</div>
<hr>
<app-unlocated-events [events]="features"></app-unlocated-events>
<app-unlocated-events [events]="filteredFeatures"></app-unlocated-events>
<app-menu></app-menu>
<hr>
<app-osm></app-osm>
<app-edit-form [selected]="selected" (saved)="onSaved($event)" (created)="onCreated($event)" (deleted)="onDeleted($event)"></app-edit-form>
</div>
<div class="main">
@if (!showTable) {
<div class="map">
<app-all-events [features]="features" [selected]="selected" (select)="onSelect($event)" (pickCoords)="onPickCoords($event)"></app-all-events>
<app-all-events [features]="filteredFeatures" [selected]="selected" (select)="onSelect($event)" (pickCoords)="onPickCoords($event)"></app-all-events>
</div>
} @else {
<div class="table-wrapper" style="overflow:auto;height:100%;">
@ -67,7 +78,7 @@
</tr>
</thead>
<tbody>
@for (f of features; track f.id) {
@for (f of filteredFeatures; track f.id) {
<tr (click)="onSelect({ id: f?.properties?.id ?? f?.id, properties: f.properties, geometry: f.geometry })" style="cursor:pointer;">
<td style="padding:6px;border-bottom:1px solid #f1f5f9;">{{f?.properties?.what}}</td>
<td style="padding:6px;border-bottom:1px solid #f1f5f9;">{{f?.properties?.label || f?.properties?.name}}</td>

View file

@ -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;
}

View file

@ -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<any> = [];
filteredFeatures: Array<any> = [];
selected: any | null = null;
showTable = false;
@ -34,6 +39,11 @@ export class Home implements OnInit, OnDestroy {
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();
this.startAutoReload();
@ -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<string>();
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 ?? ''),

View file

@ -1,6 +1,7 @@
<menu>
OpenEventDatabase
<a routerLink="/agenda">agenda</a>
<a routerLink="/unlocated-events">événements non localisés</a>
<a href="/demo/stats">stats</a>
<a href="https://source.cipherbliss.com/tykayn/oedb-backend">sources</a>

View file

@ -0,0 +1,285 @@
<div class="unlocated-events-page">
<div class="header">
<h1>Événements non localisés</h1>
<p class="subtitle">{{unlocatedEvents.length}} événement(s) nécessitant une géolocalisation</p>
@if (isLoading) {
<div class="loading">⏳ Chargement...</div>
}
</div>
<div class="content">
<div class="events-list">
<h2>Liste des événements</h2>
@if (unlocatedEvents.length === 0) {
<div class="empty-state">
<p>Aucun événement non localisé trouvé.</p>
</div>
} @else {
<div class="events-grid">
@for (event of unlocatedEvents; track event.id || event.properties?.id) {
<div class="event-card" (click)="selectEvent(event)" [class.selected]="selectedEvent?.id === event.id || selectedEvent?.properties?.id === event.properties?.id">
<div class="event-header">
<h3>{{getEventTitle(event)}}</h3>
<span class="event-type">{{event?.properties?.what || 'Non défini'}}</span>
</div>
<div class="event-details">
<p class="event-description">{{getEventDescription(event)}}</p>
<div class="event-meta">
@if (event?.properties?.start || event?.properties?.when) {
<span class="event-date">📅 {{event?.properties?.start || event?.properties?.when}}</span>
}
@if (event?.properties?.where) {
<span class="event-location">📍 {{event?.properties?.where}}</span>
}
</div>
</div>
</div>
}
</div>
}
</div>
@if (selectedEvent) {
<div class="event-editor">
<div class="editor-header">
<h2>Modifier l'événement</h2>
<div class="editor-actions">
@if (!isEditing) {
<button class="btn btn-primary" (click)="startEditing()">Modifier</button>
} @else {
<button class="btn btn-secondary" (click)="cancelEditing()">Annuler</button>
<button class="btn btn-danger" (click)="deleteEvent()" [disabled]="isLoading">Supprimer</button>
}
</div>
</div>
@if (isEditing) {
<div class="editor-content">
<!-- Géolocalisation -->
<div class="geolocation-section">
<h3>📍 Géolocalisation</h3>
<div class="search-location">
<div class="search-input-group">
<input
type="text"
class="input"
[(ngModel)]="searchQuery"
placeholder="Rechercher un lieu (ex: Paris, France)"
[disabled]="isSearchingLocation">
<button
class="btn btn-primary search-btn"
(click)="searchLocation()"
[disabled]="!searchQuery.trim() || isSearchingLocation">
@if (isSearchingLocation) {
⏳ Recherche...
} @else {
🔍 Rechercher
}
</button>
@if (nominatimResults.length > 0 || searchQuery.trim()) {
<button
class="btn btn-secondary clear-btn"
(click)="clearSearch()"
[disabled]="isSearchingLocation">
✕ Effacer
</button>
}
</div>
@if (isSearchingLocation) {
<div class="searching">Recherche en cours...</div>
}
</div>
@if (nominatimResults.length > 0) {
<div class="location-results">
<h4>Résultats de recherche ({{nominatimResults.length}} trouvé(s)) :</h4>
@for (result of nominatimResults; track result.place_id) {
<div class="location-option" (click)="selectLocation(result)" [class.selected]="selectedLocation?.place_id === result.place_id">
<div class="location-header">
<div class="location-name">{{result.display_name}}</div>
<div class="location-type">{{result.type}}</div>
</div>
<div class="location-details">
<div class="location-coords">📍 {{result.lat}}, {{result.lon}}</div>
@if (result.importance) {
<div class="location-importance">Importance: {{(result.importance * 100).toFixed(1)}}%</div>
}
</div>
@if (result.address) {
<div class="location-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}}
}
</div>
}
</div>
}
</div>
} @else if (!isSearchingLocation && searchQuery.trim() && nominatimResults.length === 0) {
<div class="no-results">Aucun résultat trouvé pour "{{searchQuery}}"</div>
}
@if (selectedLocation) {
<div class="selected-location">
<strong>Lieu sélectionné :</strong> {{selectedLocation.display_name}}
<br>
<small>Coordonnées : {{selectedLocation.lat}}, {{selectedLocation.lon}}</small>
@if (selectedLocation.address) {
<br>
<small>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}}
}
</small>
}
</div>
}
@if (selectedEvent?.geometry?.coordinates) {
<div class="current-coordinates">
<strong>Coordonnées actuelles :</strong>
{{selectedEvent.geometry.coordinates[1]}}, {{selectedEvent.geometry.coordinates[0]}}
@if (selectedEvent.geometry.coordinates[0] === 0 && selectedEvent.geometry.coordinates[1] === 0) {
<span class="warning">⚠️ Coordonnées par défaut (0,0)</span>
}
</div>
}
<!-- Formulaire de coordonnées -->
<div class="coordinates-form">
<h4>Coordonnées géographiques</h4>
<div class="coordinates-inputs">
<div class="coordinate-field">
<label for="latitude">Latitude :</label>
<input
type="number"
id="latitude"
class="input coordinate-input"
[(ngModel)]="selectedEvent.geometry.coordinates[1]"
(ngModelChange)="updateCoordinates()"
step="0.000001"
placeholder="Ex: 48.8566">
</div>
<div class="coordinate-field">
<label for="longitude">Longitude :</label>
<input
type="number"
id="longitude"
class="input coordinate-input"
[(ngModel)]="selectedEvent.geometry.coordinates[0]"
(ngModelChange)="updateCoordinates()"
step="0.000001"
placeholder="Ex: 2.3522">
</div>
</div>
<div class="coordinate-actions">
<button
class="btn btn-sm btn-secondary"
(click)="clearCoordinates()">
Effacer les coordonnées
</button>
<button
class="btn btn-sm btn-primary"
(click)="validateCoordinates()"
[disabled]="!areCoordinatesValid()">
@if (areCoordinatesValid()) {
✅ Valider les coordonnées
} @else {
Valider les coordonnées
}
</button>
</div>
@if (areCoordinatesValid()) {
<div class="coordinates-valid">
✅ Coordonnées valides et prêtes à être sauvegardées
</div>
} @else if (selectedEvent?.geometry?.coordinates[0] !== 0 || selectedEvent?.geometry?.coordinates[1] !== 0) {
<div class="coordinates-invalid">
⚠️ Coordonnées invalides ou incomplètes
</div>
}
</div>
</div>
<!-- Propriétés de l'événement -->
<div class="properties-section">
<h3>Propriétés de l'événement</h3>
<div class="properties-list">
@for (prop of getObjectKeys(selectedEvent?.properties || {}); track prop) {
<div class="property-item" [class.geocoding-property]="isGeocodingProperty(prop)">
<label class="property-key">
{{prop}}
@if (isGeocodingProperty(prop)) {
<span class="geocoding-badge">📍</span>
}
</label>
<input
type="text"
class="input property-value"
[(ngModel)]="selectedEvent.properties[prop]"
[placeholder]="'Valeur pour ' + prop"
[readonly]="isGeocodingProperty(prop) && prop !== 'where'">
@if (!isGeocodingProperty(prop) || prop === 'where') {
<button class="btn btn-sm btn-danger" (click)="removeProperty(prop)">×</button>
}
</div>
}
</div>
<!-- Ajouter une nouvelle propriété -->
<div class="add-property">
<h4>Ajouter une propriété</h4>
<div class="add-property-form">
<input
type="text"
class="input"
[(ngModel)]="newKey"
placeholder="Clé (ex: website, contact)">
<input
type="text"
class="input"
[(ngModel)]="newValue"
placeholder="Valeur">
<button class="btn btn-sm btn-primary" (click)="addProperty()">Ajouter</button>
</div>
</div>
</div>
<!-- Actions -->
<div class="editor-actions">
<button
class="btn btn-primary btn-large"
(click)="updateEvent()"
[disabled]="isLoading">
@if (isLoading) {
⏳ Mise à jour...
} @else {
💾 Mettre à jour l'événement
}
</button>
</div>
</div>
} @else {
<div class="event-preview">
<h3>Aperçu de l'événement</h3>
<div class="preview-content">
<pre>{{selectedEvent | json}}</pre>
</div>
</div>
}
</div>
}
</div>
</div>

View file

@ -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;
}
}
}

View file

@ -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<any> = [];
unlocatedEvents: Array<any> = [];
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);
}
}

View file

@ -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<OsmUser | null>(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<boolean> {
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<any>(`${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<boolean> {
if (!this.accessToken) {
return of(false);
}
return this.http.get<OsmUser>('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
}
}

View file

@ -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'
};

View file

@ -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
};

View file

@ -126,3 +126,41 @@ 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;
}
}