up display; ajout scrap agendadulibre; qa évènements sans localisation
This commit is contained in:
parent
73f18e1d31
commit
6deed13d0b
25 changed files with 2165 additions and 53 deletions
41
frontend/OSM_OAUTH_SETUP.md
Normal file
41
frontend/OSM_OAUTH_SETUP.md
Normal 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.
|
BIN
frontend/public/static/oedb.png
Normal file
BIN
frontend/public/static/oedb.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 29 KiB |
|
@ -2,6 +2,7 @@ import { Routes } from '@angular/router';
|
||||||
import {Home} from './pages/home/home';
|
import {Home} from './pages/home/home';
|
||||||
import { Agenda } from './pages/agenda/agenda';
|
import { Agenda } from './pages/agenda/agenda';
|
||||||
import { NouvellesCategories } from './pages/nouvelles-categories/nouvelles-categories';
|
import { NouvellesCategories } from './pages/nouvelles-categories/nouvelles-categories';
|
||||||
|
import { UnlocatedEventsPage } from './pages/unlocated-events/unlocated-events';
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{
|
{
|
||||||
|
@ -15,5 +16,9 @@ export const routes: Routes = [
|
||||||
{
|
{
|
||||||
path : 'nouvelles-categories',
|
path : 'nouvelles-categories',
|
||||||
component: NouvellesCategories
|
component: NouvellesCategories
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path : 'unlocated-events',
|
||||||
|
component: UnlocatedEventsPage
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
|
@ -1,17 +1,35 @@
|
||||||
<p>
|
<div class="osm-auth">
|
||||||
osm works!
|
@if (isAuthenticated) {
|
||||||
|
<div class="user-info">
|
||||||
|
<div class="user-avatar">
|
||||||
@if(isLogginIn){
|
@if (currentUser?.img?.href) {
|
||||||
<div class="pseudo">
|
<img [src]="currentUser?.img?.href" [alt]="currentUser?.display_name || 'Utilisateur OSM'" class="avatar">
|
||||||
{{osmPseudo}}
|
} @else {
|
||||||
</div>
|
<div class="avatar-placeholder">👤</div>
|
||||||
<button (click)="logout()">logout</button>
|
}
|
||||||
}
|
</div>
|
||||||
@else{
|
<div class="user-details">
|
||||||
<div class="pseudo">
|
<div class="username">{{getUsername()}}</div>
|
||||||
pas connecté
|
<div class="user-stats">
|
||||||
</div>
|
<span class="stat">{{currentUser?.changesets?.count || 0}} changesets</span>
|
||||||
<button (click)="login()">osm login</button>
|
</div>
|
||||||
}
|
</div>
|
||||||
</p>
|
<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>
|
||||||
|
}
|
||||||
|
</div>
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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({
|
@Component({
|
||||||
selector: 'app-osm',
|
selector: 'app-osm',
|
||||||
imports: [],
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
templateUrl: './osm.html',
|
templateUrl: './osm.html',
|
||||||
styleUrl: './osm.scss'
|
styleUrl: './osm.scss'
|
||||||
})
|
})
|
||||||
export class Osm {
|
export class Osm implements OnInit, OnDestroy {
|
||||||
osmPseudo: string='';
|
private osmAuth = inject(OsmAuth);
|
||||||
isLogginIn: any = false;
|
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() {
|
login() {
|
||||||
|
this.osmAuth.initiateOAuthLogin();
|
||||||
|
}
|
||||||
|
|
||||||
|
logout() {
|
||||||
|
this.osmAuth.logout();
|
||||||
|
}
|
||||||
|
|
||||||
|
getUsername(): string {
|
||||||
|
return this.osmAuth.getUsername() || '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -175,7 +175,7 @@ export class AllEvents implements OnInit, OnDestroy {
|
||||||
} else if (coords.length === 4) {
|
} else if (coords.length === 4) {
|
||||||
const maplibregl = (window as any).maplibregl;
|
const maplibregl = (window as any).maplibregl;
|
||||||
const bounds = new maplibregl.LngLatBounds([coords[0], coords[1]], [coords[2], coords[3]]);
|
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 {}
|
} 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.boxShadow = '0 0 0 4px rgba(25,118,210,0.25)';
|
||||||
el.style.borderRadius = '50%';
|
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({
|
this.select.emit({
|
||||||
id: fid,
|
id: fid,
|
||||||
properties: p,
|
properties: p,
|
||||||
geometry: { type: 'Point', coordinates: coords }
|
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();
|
const popup = marker.getPopup && marker.getPopup();
|
||||||
if (popup && popup.on) {
|
if (popup && popup.on) {
|
||||||
|
@ -272,17 +280,15 @@ export class AllEvents implements OnInit, OnDestroy {
|
||||||
bounds.extend(coords);
|
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) {
|
if (!bounds.isEmpty() && this.isInitialLoad) {
|
||||||
const hasUrlParams = this.route.snapshot.queryParams['lat'] || this.route.snapshot.queryParams['lon'] || this.route.snapshot.queryParams['zoom'];
|
const hasUrlParams = this.route.snapshot.queryParams['lat'] || this.route.snapshot.queryParams['lon'] || this.route.snapshot.queryParams['zoom'];
|
||||||
if (!hasUrlParams) {
|
if (!hasUrlParams) {
|
||||||
this.map.fitBounds(bounds, { padding: 40, maxZoom: 12 });
|
// this.map.fitBounds(bounds, { padding: 40, maxZoom: 12 });
|
||||||
}
|
}
|
||||||
this.isInitialLoad = false;
|
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 {
|
private buildMarkerElement(props: any): HTMLDivElement {
|
||||||
|
@ -365,15 +371,54 @@ export class AllEvents implements OnInit, OnDestroy {
|
||||||
private buildPopupHtml(props: any, id?: any): string {
|
private buildPopupHtml(props: any, id?: any): string {
|
||||||
const title = this.escapeHtml(String(props?.name || props?.label || props?.what || 'évènement'));
|
const title = this.escapeHtml(String(props?.name || props?.label || props?.what || 'évènement'));
|
||||||
const titleId = typeof id !== 'undefined' ? String(id) : '';
|
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 v = props[k];
|
||||||
const value = typeof v === 'object' ? `<pre>${this.escapeHtml(JSON.stringify(v, null, 2))}</pre>` : this.escapeHtml(String(v));
|
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 6px;">${this.escapeHtml(k)}</td><td style="padding:2px 6px;">${value}</td></tr>`;
|
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('');
|
}).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>`;
|
</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 {
|
private escapeHtml(s: string): string {
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
<p>unlocated-events works!</p>
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
11
frontend/src/app/page/unlocated-events/unlocated-events.ts
Normal file
11
frontend/src/app/page/unlocated-events/unlocated-events.ts
Normal 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 {
|
||||||
|
|
||||||
|
}
|
|
@ -22,9 +22,52 @@
|
||||||
(click)="setView(CalendarView.Day)">
|
(click)="setView(CalendarView.Day)">
|
||||||
Jour
|
Jour
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm"
|
||||||
|
(click)="toggleFiltersPanel()">
|
||||||
|
{{showFiltersPanel ? 'Masquer' : 'Afficher'}} les filtres
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="agenda-content">
|
||||||
<mwl-calendar-month-view
|
<mwl-calendar-month-view
|
||||||
*ngIf="view === CalendarView.Month"
|
*ngIf="view === CalendarView.Month"
|
||||||
|
|
|
@ -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 {
|
.agenda-content {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { Component, inject, OnInit, ViewChild, TemplateRef } from '@angular/core';
|
import { Component, inject, OnInit, ViewChild, TemplateRef } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
import { OedbApi } from '../../services/oedb-api';
|
import { OedbApi } from '../../services/oedb-api';
|
||||||
import { EditForm } from '../../forms/edit-form/edit-form';
|
import { EditForm } from '../../forms/edit-form/edit-form';
|
||||||
import { CalendarModule, CalendarView, CalendarEvent } from 'angular-calendar';
|
import { CalendarModule, CalendarView, CalendarEvent } from 'angular-calendar';
|
||||||
|
@ -32,7 +33,7 @@ interface DayEvents {
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-agenda',
|
selector: 'app-agenda',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, EditForm, CalendarModule],
|
imports: [CommonModule, FormsModule, EditForm, CalendarModule],
|
||||||
templateUrl: './agenda.html',
|
templateUrl: './agenda.html',
|
||||||
styleUrl: './agenda.scss'
|
styleUrl: './agenda.scss'
|
||||||
})
|
})
|
||||||
|
@ -42,13 +43,20 @@ export class Agenda implements OnInit {
|
||||||
@ViewChild('eventTitleTemplate', { static: true }) eventTitleTemplate!: TemplateRef<any>;
|
@ViewChild('eventTitleTemplate', { static: true }) eventTitleTemplate!: TemplateRef<any>;
|
||||||
|
|
||||||
events: OedbEvent[] = [];
|
events: OedbEvent[] = [];
|
||||||
|
filteredEvents: OedbEvent[] = [];
|
||||||
calendarEvents: CalendarEvent[] = [];
|
calendarEvents: CalendarEvent[] = [];
|
||||||
selectedEvent: OedbEvent | null = null;
|
selectedEvent: OedbEvent | null = null;
|
||||||
showSidePanel = false;
|
showSidePanel = false;
|
||||||
|
showFiltersPanel = false;
|
||||||
view: CalendarView = CalendarView.Month;
|
view: CalendarView = CalendarView.Month;
|
||||||
viewDate: Date = new Date();
|
viewDate: Date = new Date();
|
||||||
oedbPresets = oedb.presets.what;
|
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
|
// Exposer CalendarView pour l'utiliser dans le template
|
||||||
CalendarView = CalendarView;
|
CalendarView = CalendarView;
|
||||||
|
|
||||||
|
@ -72,12 +80,44 @@ export class Agenda implements OnInit {
|
||||||
|
|
||||||
this.oedbApi.getEvents(params).subscribe((response: any) => {
|
this.oedbApi.getEvents(params).subscribe((response: any) => {
|
||||||
this.events = Array.isArray(response?.features) ? response.features : [];
|
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() {
|
organizeEventsByDay() {
|
||||||
this.calendarEvents = this.events.map(event => {
|
this.calendarEvents = this.filteredEvents.map(event => {
|
||||||
const eventDate = this.getEventDate(event);
|
const eventDate = this.getEventDate(event);
|
||||||
const preset = this.getEventPreset(event);
|
const preset = this.getEventPreset(event);
|
||||||
|
|
||||||
|
@ -220,4 +260,33 @@ export class Agenda implements OnInit {
|
||||||
}: CalendarEventTimesChangedEvent): void {
|
}: CalendarEventTimesChangedEvent): void {
|
||||||
console.log('Event times changed:', event, newStart, newEnd);
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -2,7 +2,7 @@
|
||||||
<div class="aside">
|
<div class="aside">
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<strong>OpenEventDatabase</strong>
|
<strong>OpenEventDatabase</strong>
|
||||||
<span class="muted">{{features.length}} évènements</span>
|
<span class="muted">{{filteredFeatures.length}} évènements</span>
|
||||||
@if (isLoading) {
|
@if (isLoading) {
|
||||||
<span class="loading">⏳ Chargement...</span>
|
<span class="loading">⏳ Chargement...</span>
|
||||||
}
|
}
|
||||||
|
@ -42,18 +42,29 @@
|
||||||
|
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
<label>Filtre rapide</label>
|
<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>
|
</div>
|
||||||
<hr>
|
<hr>
|
||||||
<app-unlocated-events [events]="features"></app-unlocated-events>
|
<app-unlocated-events [events]="filteredFeatures"></app-unlocated-events>
|
||||||
<app-menu></app-menu>
|
<app-menu></app-menu>
|
||||||
<hr>
|
<hr>
|
||||||
|
<app-osm></app-osm>
|
||||||
<app-edit-form [selected]="selected" (saved)="onSaved($event)" (created)="onCreated($event)" (deleted)="onDeleted($event)"></app-edit-form>
|
<app-edit-form [selected]="selected" (saved)="onSaved($event)" (created)="onCreated($event)" (deleted)="onDeleted($event)"></app-edit-form>
|
||||||
</div>
|
</div>
|
||||||
<div class="main">
|
<div class="main">
|
||||||
@if (!showTable) {
|
@if (!showTable) {
|
||||||
<div class="map">
|
<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>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="table-wrapper" style="overflow:auto;height:100%;">
|
<div class="table-wrapper" style="overflow:auto;height:100%;">
|
||||||
|
@ -67,7 +78,7 @@
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<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;">
|
<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?.what}}</td>
|
||||||
<td style="padding:6px;border-bottom:1px solid #f1f5f9;">{{f?.properties?.label || f?.properties?.name}}</td>
|
<td style="padding:6px;border-bottom:1px solid #f1f5f9;">{{f?.properties?.label || f?.properties?.name}}</td>
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
.layout {
|
.layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 340px 1fr;
|
grid-template-columns: 400px 1fr;
|
||||||
grid-template-rows: 100vh;
|
grid-template-rows: 100vh;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
@ -14,6 +14,7 @@
|
||||||
border-right: 1px solid rgba(0,0,0,0.06);
|
border-right: 1px solid rgba(0,0,0,0.06);
|
||||||
box-shadow: 2px 0 12px rgba(0,0,0,0.03);
|
box-shadow: 2px 0 12px rgba(0,0,0,0.03);
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
padding-bottom: 150px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,8 @@ import { AllEvents } from '../../maps/all-events/all-events';
|
||||||
import { EditForm } from '../../forms/edit-form/edit-form';
|
import { EditForm } from '../../forms/edit-form/edit-form';
|
||||||
import { OedbApi } from '../../services/oedb-api';
|
import { OedbApi } from '../../services/oedb-api';
|
||||||
import { UnlocatedEvents } from '../../shared/unlocated-events/unlocated-events';
|
import { UnlocatedEvents } from '../../shared/unlocated-events/unlocated-events';
|
||||||
|
import { OsmAuth } from '../../services/osm-auth';
|
||||||
|
import { Osm } from '../../forms/osm/osm';
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-home',
|
selector: 'app-home',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
|
@ -14,6 +16,7 @@ import { UnlocatedEvents } from '../../shared/unlocated-events/unlocated-events'
|
||||||
AllEvents,
|
AllEvents,
|
||||||
UnlocatedEvents,
|
UnlocatedEvents,
|
||||||
EditForm,
|
EditForm,
|
||||||
|
Osm,
|
||||||
FormsModule
|
FormsModule
|
||||||
],
|
],
|
||||||
templateUrl: './home.html',
|
templateUrl: './home.html',
|
||||||
|
@ -23,8 +26,10 @@ export class Home implements OnInit, OnDestroy {
|
||||||
|
|
||||||
OedbApi = inject(OedbApi);
|
OedbApi = inject(OedbApi);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
|
private osmAuth = inject(OsmAuth);
|
||||||
|
|
||||||
features: Array<any> = [];
|
features: Array<any> = [];
|
||||||
|
filteredFeatures: Array<any> = [];
|
||||||
selected: any | null = null;
|
selected: any | null = null;
|
||||||
showTable = false;
|
showTable = false;
|
||||||
|
|
||||||
|
@ -33,6 +38,11 @@ export class Home implements OnInit, OnDestroy {
|
||||||
autoReloadInterval: any = null;
|
autoReloadInterval: any = null;
|
||||||
daysAhead = 7; // Nombre de jours dans le futur par défaut
|
daysAhead = 7; // Nombre de jours dans le futur par défaut
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
|
|
||||||
|
// Propriétés pour les filtres
|
||||||
|
searchText = '';
|
||||||
|
selectedWhatFilter = '';
|
||||||
|
availableWhatTypes: string[] = [];
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.loadEvents();
|
this.loadEvents();
|
||||||
|
@ -57,6 +67,8 @@ export class Home implements OnInit, OnDestroy {
|
||||||
|
|
||||||
this.OedbApi.getEvents(params).subscribe((events: any) => {
|
this.OedbApi.getEvents(params).subscribe((events: any) => {
|
||||||
this.features = Array.isArray(events?.features) ? events.features : [];
|
this.features = Array.isArray(events?.features) ? events.features : [];
|
||||||
|
this.updateAvailableWhatTypes();
|
||||||
|
this.applyFilters();
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -89,6 +101,50 @@ export class Home implements OnInit, OnDestroy {
|
||||||
this.loadEvents();
|
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() {
|
goToNewCategories() {
|
||||||
this.router.navigate(['/nouvelles-categories']);
|
this.router.navigate(['/nouvelles-categories']);
|
||||||
}
|
}
|
||||||
|
@ -106,9 +162,16 @@ export class Home implements OnInit, OnDestroy {
|
||||||
geometry: { type: 'Point', coordinates: [lon, lat] }
|
geometry: { type: 'Point', coordinates: [lon, lat] }
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
|
const osmUsername = this.osmAuth.getUsername();
|
||||||
this.selected = {
|
this.selected = {
|
||||||
id: null,
|
id: null,
|
||||||
properties: { label: '', description: '', what: '', where: '' },
|
properties: {
|
||||||
|
label: '',
|
||||||
|
description: '',
|
||||||
|
what: '',
|
||||||
|
where: '',
|
||||||
|
...(osmUsername && { last_modified_by: osmUsername })
|
||||||
|
},
|
||||||
geometry: { type: 'Point', coordinates: [lon, lat] }
|
geometry: { type: 'Point', coordinates: [lon, lat] }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -141,7 +204,7 @@ export class Home implements OnInit, OnDestroy {
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadGeoJSON() {
|
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 url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
|
@ -154,7 +217,7 @@ export class Home implements OnInit, OnDestroy {
|
||||||
|
|
||||||
downloadCSV() {
|
downloadCSV() {
|
||||||
const header = ['id', 'what', 'label', 'start', 'stop', 'lon', 'lat'];
|
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?.id ?? f?.id ?? ''),
|
||||||
JSON.stringify(f?.properties?.what ?? ''),
|
JSON.stringify(f?.properties?.what ?? ''),
|
||||||
JSON.stringify(f?.properties?.label ?? f?.properties?.name ?? ''),
|
JSON.stringify(f?.properties?.label ?? f?.properties?.name ?? ''),
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
<menu>
|
<menu>
|
||||||
OpenEventDatabase
|
OpenEventDatabase
|
||||||
<a routerLink="/agenda">agenda</a>
|
<a routerLink="/agenda">agenda</a>
|
||||||
|
<a routerLink="/unlocated-events">événements non localisés</a>
|
||||||
<a href="/demo/stats">stats</a>
|
<a href="/demo/stats">stats</a>
|
||||||
<a href="https://source.cipherbliss.com/tykayn/oedb-backend">sources</a>
|
<a href="https://source.cipherbliss.com/tykayn/oedb-backend">sources</a>
|
||||||
|
|
||||||
|
|
285
frontend/src/app/pages/unlocated-events/unlocated-events.html
Normal file
285
frontend/src/app/pages/unlocated-events/unlocated-events.html
Normal 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>
|
644
frontend/src/app/pages/unlocated-events/unlocated-events.scss
Normal file
644
frontend/src/app/pages/unlocated-events/unlocated-events.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
381
frontend/src/app/pages/unlocated-events/unlocated-events.ts
Normal file
381
frontend/src/app/pages/unlocated-events/unlocated-events.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,8 +1,222 @@
|
||||||
import { Injectable } from '@angular/core';
|
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({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class OsmAuth {
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
6
frontend/src/environments/environment.prod.ts
Normal file
6
frontend/src/environments/environment.prod.ts
Normal 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'
|
||||||
|
};
|
6
frontend/src/environments/environment.ts
Normal file
6
frontend/src/environments/environment.ts
Normal 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
|
||||||
|
};
|
|
@ -125,4 +125,42 @@ label { font-size: 0.85rem; color: $color-muted; }
|
||||||
|
|
||||||
.search{
|
.search{
|
||||||
width: 20%;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
Loading…
Add table
Add a link
Reference in a new issue