add search filter, toggle options, count unlocated events

This commit is contained in:
Tykayn 2025-10-10 16:59:13 +02:00 committed by tykayn
parent ee48a3c665
commit d22dbde2e7
12 changed files with 797 additions and 165 deletions

View file

@ -1,6 +1,7 @@
<div class="map-wrapper" style="position:relative;height:100%;">
<div #mapContainer class="map" style="width:100%;height:100%;border:1px solid #e6eef3;border-radius:10px;"></div>
<div class="search" style="position:absolute;top:10px;left:10px;right:10px;display:flex;gap:8px;">
<input #searchBox type="text" placeholder="Chercher un lieu (Nominatim)" class="input" style="flex:1;">
<button class="btn" type="button" (click)="searchPlace(searchBox.value)">Chercher</button>
</div>

View file

@ -6,6 +6,7 @@ import { EditForm } from '../../forms/edit-form/edit-form';
import { CalendarComponent, CalendarEvent } from './calendar/calendar';
import oedb from '../../../oedb-types';
import { WhatFilterComponent } from '../../shared/what-filter/what-filter';
import { ActivatedRoute } from '@angular/router';
interface OedbEvent {
id: string;
@ -34,6 +35,7 @@ interface OedbEvent {
})
export class Agenda implements OnInit {
private oedbApi = inject(OedbApi);
private route = inject(ActivatedRoute);
events: OedbEvent[] = [];
filteredEvents: OedbEvent[] = [];
@ -44,13 +46,26 @@ export class Agenda implements OnInit {
groupedEvents: Array<{ dateKey: string; date: Date; items: OedbEvent[] }> = [];
availableWhatTypes: string[] = [];
selectedWhatFilter = '';
selectedWhatFilter = 'culture';
ngOnInit() {
this.loadEvents();
this.route.queryParamMap.subscribe(map => {
const id = (map.get('id') || '').trim();
const what = (map.get('what') || '').trim();
const limitParam = map.get('limit');
const limit = limitParam ? Number(limitParam) : null;
if (id) {
this.loadSingleEvent(id);
} else {
this.loadEvents({ what: what || undefined, limit: limit || undefined });
}
if (what) {
this.selectedWhatFilter = what;
}
});
}
loadEvents() {
loadEvents(overrides: { what?: string; limit?: number } = {}) {
this.isLoading = true;
const today = new Date();
const startDate = new Date(today);
@ -58,11 +73,12 @@ export class Agenda implements OnInit {
const endDate = new Date(today);
endDate.setMonth(today.getMonth() + 3); // Charger 3 mois après
const params = {
const params: any = {
start: startDate.toISOString().split('T')[0],
end: endDate.toISOString().split('T')[0],
limit: 1000
limit: overrides.limit ?? 1000
};
if (overrides.what) params.what = overrides.what;
this.oedbApi.getEvents(params).subscribe((response: any) => {
this.events = Array.isArray(response?.features) ? response.features : [];
@ -72,6 +88,28 @@ export class Agenda implements OnInit {
});
}
loadSingleEvent(id: string | number) {
this.isLoading = true;
this.oedbApi.getEventById(id).subscribe({
next: (feature: any) => {
const f = (feature && (feature as any).type === 'Feature') ? feature : (feature?.feature || null);
this.events = f ? [f] as OedbEvent[] : [];
this.filteredEvents = this.events;
this.updateAvailableWhatTypes();
this.applyWhatFilter();
this.isLoading = false;
},
error: () => {
this.events = [];
this.filteredEvents = [];
this.calendarEvents = [];
this.filteredCalendarEvents = [];
this.groupedEvents = [];
this.isLoading = false;
}
});
}
convertToCalendarEvents() {
const source = this.filteredEvents.length ? this.filteredEvents : this.events;
this.calendarEvents = source.map(event => {
@ -150,7 +188,8 @@ export class Agenda implements OnInit {
applyWhatFilter() {
if (this.selectedWhatFilter) {
this.filteredEvents = this.events.filter(e => e?.properties?.what === this.selectedWhatFilter);
const prefix = this.selectedWhatFilter;
this.filteredEvents = this.events.filter(e => String(e?.properties?.what || '').startsWith(prefix));
} else {
this.filteredEvents = [...this.events];
}

View file

@ -25,8 +25,10 @@ export class CommunityUpcoming {
// when: NEXT7DAYS etc. Utilise le param 'when' déjà supporté par l'API
const d = Math.max(1, Number(this.days()) || 7);
const when = `NEXT${d}DAYS`;
this.api.getEvents({ when, what: 'commu', limit: 1000 }).subscribe((events: any) => {
this.features = Array.isArray(events?.features) ? events.features : [];
this.api.getEvents({ when, what: 'community', limit: 1000 }).subscribe((events: any) => {
const list = Array.isArray(events?.features) ? events.features : [];
// Filtrer côté client pour tout ce qui commence par "community"
this.features = list.filter((f: any) => String(f?.properties?.what || '').startsWith('community'));
});
}
}

View file

@ -4,7 +4,7 @@
<p>(1000 prochains pour les 30 prochains jours)</p>
<ul>
<li *ngFor="let c of counts">
<button (click)="filterByWhat(c.what)"> {{ c.what }} <span class="badge">{{ c.count }}</span></button>
<button (click)="filterByWhat(c.what)" [class.selected]="selectedWhatType === c.what"> {{ c.what }} <span class="badge">{{ c.count }}</span></button>
</li>
</ul>
@ -14,4 +14,62 @@
</section>
</section>
@if (selectedWhatType && selectedTypeDetails) {
<div class="type-details-panel">
<div class="panel-content">
<div class="panel-header">
<h3>
@if (getEmojiForWhat(selectedWhatType)) {
<span class="emoji">{{ getEmojiForWhat(selectedWhatType) }}</span>
}
@if (getImageForWhat(selectedWhatType)) {
<img [src]="getImageForWhat(selectedWhatType)" alt="" class="type-image">
}
{{ selectedTypeDetails.label || selectedWhatType }}
</h3>
<button class="close-btn" (click)="selectedWhatType = null; selectedTypeDetails = null">×</button>
</div>
<div class="panel-body">
<p class="description">{{ selectedTypeDetails.description || 'Aucune description disponible' }}</p>
@if (selectedTypeDetails.category) {
<div class="detail-item">
<strong>Catégorie:</strong> {{ selectedTypeDetails.category }}
</div>
}
@if (selectedTypeDetails.label && selectedTypeDetails.label !== selectedWhatType) {
<div class="detail-item">
<strong>Nom court:</strong> {{ selectedTypeDetails.label }}
</div>
}
@if (selectedTypeDetails.durationHours) {
<div class="detail-item">
<strong>Durée par défaut:</strong> {{ selectedTypeDetails.durationHours }} heures
</div>
}
@if (getPropertiesForWhat(selectedWhatType)) {
<div class="detail-item">
<strong>Propriétés disponibles:</strong>
<ul class="properties-list">
@for (prop of getObjectKeys(getPropertiesForWhat(selectedWhatType)); track prop) {
<li>
<strong>{{ prop }}:</strong>
{{ getPropertiesForWhat(selectedWhatType)[prop].label || prop }}
@if (getPropertiesForWhat(selectedWhatType)[prop].description) {
<span class="prop-desc">({{ getPropertiesForWhat(selectedWhatType)[prop].description }})</span>
}
</li>
}
</ul>
</div>
}
</div>
</div>
</div>
}

View file

@ -21,4 +21,103 @@
min-height: 60vh;
}
.type-details-panel {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #fff;
border-top: 2px solid #1976d2;
box-shadow: 0 -4px 12px rgba(0,0,0,0.15);
z-index: 1000;
max-height: 50vh;
overflow: hidden;
}
.panel-content {
padding: 16px;
max-height: 50vh;
overflow-y: auto;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #e0e0e0;
}
.panel-header h3 {
margin: 0;
display: flex;
align-items: center;
gap: 8px;
}
.emoji {
font-size: 1.2em;
}
.type-image {
width: 24px;
height: 24px;
object-fit: contain;
}
.close-btn {
background: #f5f5f5;
border: 1px solid #ddd;
border-radius: 4px;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 18px;
line-height: 1;
}
.close-btn:hover {
background: #e0e0e0;
}
.panel-body {
.description {
margin: 0 0 12px 0;
color: #555;
line-height: 1.4;
}
}
.detail-item {
margin-bottom: 8px;
strong {
color: #1976d2;
}
}
.properties-list {
margin: 8px 0 0 16px;
padding: 0;
li {
margin-bottom: 4px;
list-style: none;
.prop-desc {
color: #666;
font-style: italic;
}
}
}
button.selected {
background: #e3f2fd;
border-color: #1976d2;
}

View file

@ -2,6 +2,7 @@ import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { OedbApi } from '../../services/oedb-api';
import { AllEvents } from '../../maps/all-events/all-events';
import oedb from '../../../oedb-types';
@Component({
selector: 'app-events-docs',
@ -16,6 +17,8 @@ export class EventsDocs {
counts: Array<{ what: string, count: number }> = [];
filtered: Array<any> = [];
selected: any | null = null;
selectedWhatType: string | null = null;
selectedTypeDetails: any = null;
ngOnInit() {
// Charger 1000 events récents
@ -38,6 +41,36 @@ export class EventsDocs {
filterByWhat(what: string) {
this.filtered = this.features.filter(f => String(f?.properties?.what || '').startsWith(what));
this.selectedWhatType = what;
this.loadTypeDetails(what);
}
loadTypeDetails(what: string) {
const presets = oedb.presets.what as any;
this.selectedTypeDetails = presets[what] || null;
}
getEmojiForWhat(what: string): string | null {
const details = this.selectedTypeDetails;
return details?.emoji || null;
}
getImageForWhat(what: string): string | null {
const details = this.selectedTypeDetails;
if (details?.image) {
const img: string = details.image;
return img.startsWith('/') ? img : `/${img}`;
}
return null;
}
getPropertiesForWhat(what: string): any {
const details = this.selectedTypeDetails;
return details?.properties || null;
}
getObjectKeys(obj: any): string[] {
return Object.keys(obj || {});
}
}

View file

@ -1,42 +1,46 @@
<div class="layout">
@if(showOptions){
<div class="aside">
<div class="toolbar">
<!-- <span class="loading-indicator">⏳</span> -->
@if (isLoading) {
<span class="loading-indicator"></span>
}
</div>
<div class="filters">
<label (click)="showFilters = !showFilters">
Filtre rapide
@if (showFilters) {
<span></span>
} @else {
<span></span>
<div class="aside-content">
<div class="toolbar">
<!-- <span class="loading-indicator">⏳</span> -->
@if (isLoading) {
<span class="loading-indicator"></span>
}
</label>
<div class="filters-group">
@if (showFilters) {
<span class="muted">{{filteredFeatures.length}} évènements chargés</span>
<hr>
<div class="controls">
<div class="control-group">
<label>Jours à venir</label>
<input
type="number"
class="input"
[(ngModel)]="daysAhead"
(ngModelChange)="onDaysAheadChange()"
min="1"
max="30"
placeholder="7">
</div>
</div>
<div class="filters">
<label (click)="showFilters = !showFilters">
Filtre rapide
@if (showFilters) {
<span></span>
} @else {
<span></span>
}
</label>
<div class="filters-group">
@if (showFilters) {
<span class="muted">{{filteredFeatures.length}} évènements chargés</span>
<hr>
<div class="controls">
<div class="control-group">
<label>Jours à venir</label>
<input
type="number"
class="input"
[(ngModel)]="daysAhead"
(ngModelChange)="onDaysAheadChange()"
min="1"
max="30"
placeholder="7">
</div>
<div class="control-group">
<label>
<input
@ -46,102 +50,130 @@
Rechargement auto (1min)
</label>
</div>
</div>
<div class="control-group">
<label>
<input
type="checkbox"
[(ngModel)]="useBboxFilter"
(change)="onBboxFilterToggle()">
Filtrer par zone visible (bbox)
</label>
</div>
</div>
<input class="input" type="text" placeholder="Rechercher..." [(ngModel)]="searchText" (ngModelChange)="onSearchChange()">
<div class="control-group">
<app-what-filter [label]="'Filtrer par type d\'événement'" [available]="availableWhatTypes" [selected]="selectedWhatFilter" (selectedChange)="selectedWhatFilter = $event; onWhatFilterChange()"></app-what-filter>
<label>Période (début / fin)</label>
<div style="display:flex; gap:6px;">
<input class="input" type="date" [(ngModel)]="startDateStr">
<input class="input" type="date" [(ngModel)]="endDateStr">
</div>
</div>
<app-osm></app-osm>
<app-menu></app-menu>
<hr>
}
</div>
</div>
<!-- <app-unlocated-events [events]="filteredFeatures"></app-unlocated-events> -->
<hr>
@if(showEditForm){
<div class="guide">
<h3>Guide</h3>
<ul>
<li> Créer un évènement: Cliquez sur le bouton "+" pour créer un nouvel évènement. Sélectionnez un preset, remplissez les informations, cliquez quelque part sur la carte pour définir un emplacement. Puis appuyez sur créer.</li>
<li> Mettre à jour un évènement: Sélectionnez un évènement sur la carte ou dans la liste pour le modifier.</li>
</ul>
</div>
<app-edit-form [selected]="selected" (saved)="onSaved($event)" (created)="onCreated($event)" (deleted)="onDeleted($event)" (canceled)="onCanceled()"></app-edit-form>
}
<div id="fixed_actions">
<button class="button btn btn-primary" (click)="createEvent()" title="Créer un évènement">+ nouvel évènement</button>
<button class="button" (click)="toggleView()" title="Basculer carte / tableau">📊</button>
<div class="downloaders">
<button class="button" (click)="downloadGeoJSON()" title="Télécharger GeoJSON">📥 GeoJSON</button>
<button class="button" (click)="downloadCSV()" title="Télécharger CSV">📥 CSV</button>
<div class="control-group">
<button class="btn" (click)="onQuickSearchSubmit()">Rechercher</button>
</div>
<div class="selectors">
<button class="button" [class.active]="selectionMode==='rectangle'" (click)="startRectSelection()" title="Sélection rectangulaire"></button>
<button class="button" [class.active]="selectionMode==='polygon'" (click)="startPolySelection()" title="Sélection polygone"></button>
@if (selectedIds.length) {
<span class="muted">{{selectedIds.length}} sélectionné(s)</span>
<div class="control-group">
<app-what-filter [label]="'Filtrer par type d\'événement'" [available]="availableWhatTypes" [selected]="selectedWhatFilter" (selectedChange)="selectedWhatFilter = $event; onWhatFilterChange()"></app-what-filter>
</div>
<app-osm></app-osm>
<app-menu></app-menu>
<hr>
}
</div>
</div>
</div>
<!-- <app-unlocated-events [events]="filteredFeatures"></app-unlocated-events> -->
<hr>
@if(showEditForm){
<div class="guide">
<h3>Guide</h3>
<ul>
<li> Créer un évènement: Cliquez sur le bouton "+" pour créer un nouvel évènement. Sélectionnez un preset, remplissez les informations, cliquez quelque part sur la carte pour définir un emplacement. Puis appuyez sur créer.</li>
<li> Mettre à jour un évènement: Sélectionnez un évènement sur la carte ou dans la liste pour le modifier.</li>
</ul>
</div>
<app-edit-form [selected]="selected" (saved)="onSaved($event)" (created)="onCreated($event)" (deleted)="onDeleted($event)" (canceled)="onCanceled()"></app-edit-form>
}
<div id="fixed_actions">
<button class="button btn btn-primary" (click)="createEvent()" title="Créer un évènement">+ nouvel évènement</button>
<button class="button" (click)="toggleView()" title="Basculer carte / tableau">📊</button>
<div class="downloaders">
<button class="button" (click)="downloadGeoJSON()" title="Télécharger GeoJSON">📥 GeoJSON</button>
<button class="button" (click)="downloadCSV()" title="Télécharger CSV">📥 CSV</button>
</div>
<div class="selectors">
<button class="button" [class.active]="selectionMode==='rectangle'" (click)="startRectSelection()" title="Sélection rectangulaire"></button>
<button class="button" [class.active]="selectionMode==='polygon'" (click)="startPolySelection()" title="Sélection polygone"></button>
@if (selectedIds.length) {
<span class="muted">{{selectedIds.length}} sélectionné(s)</span>
}
</div>
</div>
@if(selected !== null){
<div class="selected">
<h3> sélectionné: {{selected.properties.name}}</h3>
{{selected.properties.label}}
<br>
{{selected.properties.label}}
<br>
{{selected.properties.what}}
<br>
{{selected.properties.where}}
<br>
{{selected.properties.lat}}
<br>
{{selected.properties.lon}}
<br>
{{selected.properties.wikidata}}
<br>
{{selected.properties.featureType}}
<br>
{{selected.properties.type}}
<br>
start:
{{selected.properties.start}}
<br>
end:
{{selected.properties.stop}}
<br>
source
{{selected.properties.source}}
<br>
description:
{{selected.properties.description}}
<br>
createdate:
{{selected.properties.createdate}}
<br>
lastupdate:
{{selected.properties.lastupdate}}
@if(selected !== null){
<div class="selected">
<h3> sélectionné: {{selected.properties.name}}</h3>
{{selected.properties.label}}
<br>
{{selected.properties.label}}
<br>
{{selected.properties.what}}
<br>
{{selected.properties.where}}
<br>
{{selected.properties.lat}}
<br>
{{selected.properties.lon}}
<br>
{{selected.properties.wikidata}}
<br>
{{selected.properties.featureType}}
<br>
{{selected.properties.type}}
<br>
start:
{{selected.properties.start}}
<br>
end:
{{selected.properties.stop}}
<br>
source
{{selected.properties.source}}
<br>
description:
{{selected.properties.description}}
<br>
createdate:
{{selected.properties.createdate}}
<br>
lastupdate:
{{selected.properties.lastupdate}}
</div>
}
</div>
<div class="main">
}
</div>
</div>
}
<div class="main {{showOptions? 'is-small' : 'is-full'}}">
<button class="button toggle-options" (click)="showOptions = !showOptions">
Options
</button>
@if (theme()) {
<div class="subtheme-bar">
<div class="help">Thème: {{ theme() }} — Cliquez sur la carte pour définir des coordonnées puis créez un évènement du sous-thème choisi.</div>
@ -155,10 +187,54 @@ lastupdate:
</div>
</div>
}
@if (!showTable) {
@if (!showTable && !showUnlocatedList) {
<div class="map">
<app-all-events [features]="filteredFeatures" [selected]="selected" [selectMode]="selectionMode" (selection)="onSelection($event)" (select)="onSelect($event)" (pickCoords)="onPickCoords($event)"></app-all-events>
</div>
} @else if (showUnlocatedList) {
<div class="table-wrapper" style="overflow:auto;height:100%;">
<div class="unlocated-layout">
<aside class="agenda-sidebar">
<div class="sidebar-header">
<h3>Sans lieu / en ligne</h3>
<small>{{unlocatedOrOnline.length}} évènements</small>
</div>
<div class="day-groups">
<ul class="event-list">
@for (f of unlocatedOrOnline; track f.id) {
<li class="event-item" (click)="onSelect({ id: f?.properties?.id ?? f?.id, properties: f.properties, geometry: f.geometry })" [class.active]="selected?.id === (f?.properties?.id ?? f?.id)">
<span class="event-icon">📌</span>
<div class="event-meta">
<div class="event-title">{{f?.properties?.label || f?.properties?.name || 'Événement'}}</div>
<div class="event-when">{{f?.properties?.start || f?.properties?.when || '—'}}</div>
</div>
</li>
}
</ul>
</div>
</aside>
<main class="agenda-main">
@if (selected) {
<div class="event-edit-panel">
<div class="panel-header">
<h3>Détails</h3>
<button class="btn-close" (click)="selected = null">×</button>
</div>
<div class="panel-content">
<app-edit-form
[selected]="selected"
(saved)="onSaved($event)"
(created)="onCreated($event)"
(deleted)="onDeleted($event)">
</app-edit-form>
</div>
</div>
} @else {
<div class="hint">Sélectionnez un évènement à gauche pour voir les détails.</div>
}
</main>
</div>
</div>
} @else {
<div class="table-wrapper" style="overflow:auto;height:100%;">
<table style="width:100%;border-collapse:collapse;">
@ -194,6 +270,7 @@ lastupdate:
<select class="input" [(ngModel)]="batchAction">
<option value="none">Choisir...</option>
<option value="changeWhat">Changer le type d'évènement (what)</option>
<option value="setField">Remplacer une propriété</option>
<option value="delete">Supprimer</option>
</select>
</div>
@ -203,10 +280,36 @@ lastupdate:
<input class="input" type="text" [(ngModel)]="batchWhat" placeholder="ex: traffic.roadwork" />
</div>
}
@if (batchAction==='setField') {
<div class="row">
<label>Clé de propriété</label>
<input class="input" type="text" [(ngModel)]="batchFieldKey" placeholder="ex: where" />
</div>
<div class="row">
<label>Nouvelle valeur</label>
<input class="input" type="text" [(ngModel)]="batchFieldValue" placeholder="ex: Paris, 12e" />
</div>
}
<div class="actions">
<button class="btn" (click)="applyBatch()" [disabled]="batchAction==='none'">Appliquer</button>
<button class="btn btn-ghost" (click)="clearSelection()">Annuler</button>
</div>
@if (batchSummary) {
<div class="summary">
<span>Succès: {{batchSummary.success}}</span>
<span>Échecs: {{batchSummary.failed}}</span>
<span>Erreurs réseau: {{batchSummary.networkErrors}}</span>
</div>
}
</div>
</div>
}
<!-- Boutons flottants en bas à droite -->
<div class="floating-actions">
<button class="fab counter" (click)="toggleUnlocatedPanel()" title="Non localisés ou en ligne">
{{unlocatedOrOnline.length}}
</button>
<button class="fab plus" (click)="createMammoth()" title="Créer un nouvel évènement (mammouth)">+
</button>
</div>

View file

@ -23,6 +23,13 @@
grid-template-columns: 400px 1fr;
grid-template-rows: 100vh;
gap: 0;
&.is-small{
grid-template-columns: 100px 1fr;
}
/* Quand la zone principale est en plein écran */
&:has(.main.is-full){
grid-template-columns: 1fr;
}
}
.aside {
@ -32,6 +39,10 @@
padding: 16px;
padding-bottom: 150px;
overflow: auto;
/* Masquer l'aside en mode plein écran */
.layout:has(.main.is-full) &{
display: none;
}
}
.main {
@ -39,6 +50,9 @@
flex-direction: column;
height: 100vh;
overflow: hidden;
&.is-full{
flex-grow: 1;
}
}
.toolbar {
@ -115,6 +129,58 @@
flex: 1 1 auto;
min-height: 0;
}
.unlocated-layout{
display: grid;
grid-template-columns: 360px 1fr;
height: 100%;
}
.agenda-sidebar{
overflow: auto;
border-right: 1px solid #eee;
padding: 10px;
}
.event-list{ list-style: none; padding: 0; margin: 0; }
.event-item{ display: flex; gap: 8px; padding: 8px; cursor: pointer; }
.event-item.active{ background: #f0f9ff; }
.event-title{ font-weight: 600; }
.event-when{ font-size: 12px; color: #64748b; }
.agenda-main{ padding: 10px; }
.event-edit-panel{ border: 1px solid #e2e8f0; border-radius: 8px; background: #fff; }
.panel-header{ display:flex; justify-content: space-between; align-items:center; padding: 8px 12px; border-bottom: 1px solid #e2e8f0; }
.panel-content{ padding: 8px; }
.btn-close{ background: #f5f5f5; border: 1px solid #ddd; border-radius: 4px; width: 32px; height: 32px; cursor: pointer; }
.hint{ color: #64748b; padding: 20px; }
.floating-actions{
position: fixed;
right: 16px;
bottom: 16px;
display: flex;
flex-direction: column;
gap: 10px;
z-index: 2000;
.fab{
width: 48px;
height: 48px;
border-radius: 50%;
border: none;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 16px;
background: #1976d2;
color: #fff;
}
.fab.counter{
background: #0d9488;
}
.fab.plus{
background: #1976d2;
}
}
.presets{
@ -125,6 +191,10 @@
max-height: 80vh;
display: block;
/* En plein écran, plus d'offset gauche */
.layout:has(.main.is-full) &{
margin-left: 0;
}
}
app-edit-form{
position: fixed;
@ -142,6 +212,10 @@ app-edit-form{
box-shadow: 0 0 10px rgba(0,0,0,0.1);
z-index: 1000;
padding-bottom: 150px;
/* En plein écran, plus d'offset gauche */
.layout:has(.main.is-full) &{
margin-left: 0;
}
}
.subtheme-bar {
@ -155,3 +229,25 @@ app-edit-form{
.chip.active { background: #e3f2fd; border-color: #90caf9; }
.emoji { margin-right: 6px; }
}
.toggle-options{
position: fixed;
bottom: 1rem;
left: 1rem;
z-index: 100;
background: white;
border: 1px solid rgba(0,0,0,0.06);
border-radius: 10px;
padding: 10px;
cursor: pointer;
&:hover{
background-color: #f0f0f0;
}
&:active{
background-color: #e0e0e0;
}
&:focus{
outline: none;
}
}

View file

@ -39,33 +39,68 @@ export class Home implements OnInit, OnDestroy {
selected: any | null = null;
showTable = false;
showFilters = false;
showEditForm = true;
showEditForm = false;
showOptions = false;
selectionMode: 'none' | 'rectangle' | 'polygon' = 'none';
selectedIds: Array<string | number> = [];
batchAction: 'none' | 'changeWhat' | 'delete' = 'none';
batchAction: 'none' | 'changeWhat' | 'setField' | 'delete' = 'none';
batchWhat = '';
batchFieldKey = '';
batchFieldValue: any = '';
batchSummary: { success: number; failed: number; networkErrors: number } | null = null;
// Nouvelles propriétés pour le rechargement automatique et la sélection de jours
autoReloadEnabled = true;
autoReloadInterval: any = null;
daysAhead = 7; // Nombre de jours dans le futur par défaut
isLoading = false;
// Formulaire de recherche
startDateStr: string | null = null;
endDateStr: string | null = null;
// Propriétés pour les filtres
searchText = '';
selectedWhatFilter = '';
selectedWhatFilter = 'culture';
availableWhatTypes: string[] = [];
theme = signal<string | null>(null);
subthemes: Array<{ key: string, label: string, emoji: string }> = [];
activeSubtheme = signal<string | null>(null);
// Option bbox
useBboxFilter = false;
currentBbox: { minLng: number, minLat: number, maxLng: number, maxLat: number } | null = null;
// Debounce pour la recherche
private searchDebounceTimer: any = null;
// Non localisés / en ligne
unlocatedOrOnline: Array<any> = [];
showUnlocatedList = false;
ngOnInit() {
this.loadEvents();
this.route.queryParamMap.subscribe(map => {
const id = (map.get('id') || '').trim();
const what = (map.get('what') || '').trim();
const limitParam = map.get('limit');
const limit = limitParam ? Number(limitParam) : null;
// Charger selon les query params
if (id) {
this.loadSingleEvent(id);
} else {
this.loadEvents({ what: what || undefined, limit: limit || undefined });
}
// Appliquer filtre par what côté client si fourni
if (what) {
this.selectedWhatFilter = what;
}
});
this.startAutoReload();
}
ngOnDestroy() {
this.stopAutoReload();
// Nettoyer le timer de debounce
if (this.searchDebounceTimer) {
clearTimeout(this.searchDebounceTimer);
}
}
createEvent() {
@ -76,26 +111,61 @@ export class Home implements OnInit, OnDestroy {
}
loadEvents() {
loadEvents(overrides: { what?: string; limit?: number; start?: string; end?: string; daysAhead?: number } = {}) {
this.isLoading = true;
const today = new Date();
const endDate = new Date(today);
endDate.setDate(today.getDate() + this.daysAhead);
const startIso = overrides.start || this.startDateStr || today.toISOString().split('T')[0];
let endIso = overrides.end || this.endDateStr || '';
if (!endIso) {
const d = new Date(today);
const span = overrides.daysAhead ?? this.daysAhead;
d.setDate(today.getDate() + span);
endIso = d.toISOString().split('T')[0];
}
const params = {
start: today.toISOString().split('T')[0],
end: endDate.toISOString().split('T')[0],
limit: 3000
const params: any = {
start: startIso,
end: endIso,
limit: overrides.limit ?? 10000
};
if (overrides.what) {
params.what = overrides.what;
} else if (this.selectedWhatFilter) {
params.what = this.selectedWhatFilter;
}
// Ajouter bbox si activé et disponible
if (this.useBboxFilter && this.currentBbox) {
params.bbox = `${this.currentBbox.minLng},${this.currentBbox.minLat},${this.currentBbox.maxLng},${this.currentBbox.maxLat}`;
}
this.OedbApi.getEvents(params).subscribe((events: any) => {
this.features = Array.isArray(events?.features) ? events.features : [];
this.computeUnlocatedOrOnline();
this.updateAvailableWhatTypes();
this.applyFilters();
this.isLoading = false;
});
}
loadSingleEvent(id: string | number) {
this.isLoading = true;
this.OedbApi.getEventById(id).subscribe({
next: (feature: any) => {
const f = (feature && (feature as any).type === 'Feature') ? feature : (feature?.feature || null);
this.features = f ? [f] : [];
this.filteredFeatures = this.features;
this.updateAvailableWhatTypes();
this.isLoading = false;
},
error: () => {
this.features = [];
this.filteredFeatures = [];
this.isLoading = false;
}
});
}
startAutoReload() {
if (this.autoReloadEnabled && !this.autoReloadInterval) {
this.autoReloadInterval = setInterval(() => {
@ -121,7 +191,7 @@ export class Home implements OnInit, OnDestroy {
}
onDaysAheadChange() {
this.loadEvents();
this.loadEvents({ daysAhead: this.daysAhead, what: this.selectedWhatFilter || undefined });
}
updateAvailableWhatTypes() {
@ -141,7 +211,15 @@ export class Home implements OnInit, OnDestroy {
}
onSearchChange() {
this.applyFilters();
// Annuler le timer précédent s'il existe
if (this.searchDebounceTimer) {
clearTimeout(this.searchDebounceTimer);
}
// Créer un nouveau timer de 500ms
this.searchDebounceTimer = setTimeout(() => {
this.applyFilters();
}, 500);
}
onWhatFilterChange() {
@ -189,6 +267,8 @@ export class Home implements OnInit, OnDestroy {
...this.selected,
geometry: { type: 'Point', coordinates: [lon, lat] }
};
this.showOptions = true;
} else {
const osmUsername = this.osmAuth.getUsername();
const whatKey = this.activeSubtheme();
@ -246,34 +326,51 @@ export class Home implements OnInit, OnDestroy {
this.selectedIds = [];
this.batchAction = 'none';
this.batchWhat = '';
this.batchFieldKey = '';
this.batchFieldValue = '';
this.batchSummary = null;
}
async applyBatch() {
if (!this.selectedIds.length || this.batchAction === 'none') return;
let success = 0;
let failed = 0;
let networkErrors = 0;
const doUpdate = async (id: string | number, updater: (f: any) => any) => {
const feature = this.features.find(f => (f?.properties?.id ?? f?.id) === id);
if (!feature) { failed++; return; }
const updated = updater(feature);
await new Promise<void>((resolve) => {
this.OedbApi.updateEvent(id, updated).subscribe({
next: () => { success++; resolve(); },
error: (err) => { (err?.status === 0 ? networkErrors++ : failed++); resolve(); }
});
});
};
if (this.batchAction === 'delete') {
for (const id of this.selectedIds) {
await new Promise<void>((resolve) => {
this.OedbApi.deleteEvent(id).subscribe({ next: () => resolve(), error: () => resolve() });
this.OedbApi.deleteEvent(id).subscribe({ next: () => { success++; resolve(); }, error: (err) => { (err?.status === 0 ? networkErrors++ : failed++); resolve(); } });
});
}
this.loadEvents();
this.clearSelection();
return;
}
if (this.batchAction === 'changeWhat') {
} else if (this.batchAction === 'changeWhat') {
const what = this.batchWhat.trim();
if (!what) return;
for (const id of this.selectedIds) {
const feature = this.features.find(f => (f?.properties?.id ?? f?.id) === id);
if (!feature) continue;
const updated = { ...feature, properties: { ...feature.properties, what } };
await new Promise<void>((resolve) => {
this.OedbApi.updateEvent(id, updated).subscribe({ next: () => resolve(), error: () => resolve() });
});
await doUpdate(id, (feature: any) => ({ ...feature, properties: { ...feature.properties, what } }));
}
} else if (this.batchAction === 'setField') {
const key = this.batchFieldKey.trim();
if (!key) return;
for (const id of this.selectedIds) {
await doUpdate(id, (feature: any) => ({ ...feature, properties: { ...feature.properties, [key]: this.batchFieldValue } }));
}
this.loadEvents();
this.clearSelection();
}
this.batchSummary = { success, failed, networkErrors };
this.loadEvents();
}
onCanceled() {
@ -288,6 +385,46 @@ export class Home implements OnInit, OnDestroy {
this.showTable = !this.showTable;
}
private isNonLocated(feature: any): boolean {
const geom = feature?.geometry;
if (!geom || geom.type !== 'Point') return true;
const coords = geom.coordinates;
if (!Array.isArray(coords) || coords.length !== 2) return true;
const [lon, lat] = coords;
if (lon == null || lat == null) return true;
if (lon === 0 && lat === 0) return true;
return false;
}
private isOnline(feature: any): boolean {
const v = feature?.properties?.online;
return v === 'yes' || v === true;
}
private computeUnlocatedOrOnline() {
this.unlocatedOrOnline = (this.features || []).filter(f => this.isNonLocated(f) || this.isOnline(f));
}
toggleUnlocatedPanel() {
this.showUnlocatedList = !this.showUnlocatedList;
}
createMammoth() {
const osmUsername = this.osmAuth.getUsername();
this.selected = {
id: null,
properties: {
label: '',
description: '',
what: 'traffic.mammoth',
where: '',
...(osmUsername && { last_modified_by: osmUsername })
},
geometry: { type: 'Point', coordinates: [0, 0] }
};
this.showEditForm = true;
}
private buildSubthemes() {
const t = this.theme();
if (!t) { this.subthemes = []; this.activeSubtheme.set(null); return; }
@ -337,4 +474,34 @@ export class Home implements OnInit, OnDestroy {
URL.revokeObjectURL(url);
a.remove();
}
onQuickSearchSubmit() {
const start = (this.startDateStr || '').trim() || undefined;
const end = (this.endDateStr || '').trim() || undefined;
const days = this.daysAhead;
const what = (this.selectedWhatFilter || '').trim() || undefined;
this.loadEvents({ start, end, daysAhead: days, what });
}
onBboxFilterToggle() {
this.useBboxFilter = !this.useBboxFilter;
if (this.useBboxFilter) {
// Demander la bbox actuelle à la carte
this.requestCurrentBbox();
}
this.loadEvents();
}
requestCurrentBbox() {
// Cette méthode sera appelée par le composant de carte
// pour obtenir la bbox actuelle
console.log('Demande de bbox actuelle...');
}
setCurrentBbox(bbox: { minLng: number, minLat: number, maxLng: number, maxLat: number }) {
this.currentBbox = bbox;
if (this.useBboxFilter) {
this.loadEvents();
}
}
}

View file

@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { OedbApi } from '../../services/oedb-api';
import { OsmAuth } from '../../services/osm-auth';
import { ActivatedRoute } from '@angular/router';
interface NominatimResult {
place_id: number;
@ -31,6 +32,7 @@ interface NominatimResult {
export class UnlocatedEventsPage implements OnInit {
OedbApi = inject(OedbApi);
private osmAuth = inject(OsmAuth);
private route = inject(ActivatedRoute);
events: Array<any> = [];
unlocatedEvents: Array<any> = [];
@ -47,20 +49,31 @@ export class UnlocatedEventsPage implements OnInit {
selectedLocation: NominatimResult | null = null;
ngOnInit() {
this.loadEvents();
this.route.queryParamMap.subscribe(map => {
const id = (map.get('id') || '').trim();
const what = (map.get('what') || '').trim();
const limitParam = map.get('limit');
const limit = limitParam ? Number(limitParam) : null;
if (id) {
this.loadSingleEvent(id);
} else {
this.loadEvents({ what: what || undefined, limit: limit || undefined });
}
});
}
loadEvents() {
loadEvents(overrides: { what?: string; limit?: number } = {}) {
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 = {
const params: any = {
start: today.toISOString().split('T')[0],
end: endDate.toISOString().split('T')[0],
limit: 1000
limit: overrides.limit ?? 1000
};
if (overrides.what) params.what = overrides.what;
this.OedbApi.getEvents(params).subscribe((events: any) => {
this.events = Array.isArray(events?.features) ? events.features : [];
@ -69,6 +82,23 @@ export class UnlocatedEventsPage implements OnInit {
});
}
loadSingleEvent(id: string | number) {
this.isLoading = true;
this.OedbApi.getEventById(id).subscribe({
next: (feature: any) => {
const f = (feature && (feature as any).type === 'Feature') ? feature : (feature?.feature || null);
this.events = f ? [f] : [];
this.filterUnlocatedEvents();
this.isLoading = false;
},
error: () => {
this.events = [];
this.unlocatedEvents = [];
this.isLoading = false;
}
});
}
filterUnlocatedEvents() {
this.unlocatedEvents = (this.events || []).filter(ev => {
// Vérifie si la géométrie est un point

View file

@ -14,6 +14,10 @@ export class OedbApi {
return this.http.get(`${this.baseUrl}/event`, { params });
}
getEventById(id: string | number) {
return this.http.get(`${this.baseUrl}/event/${id}`);
}
createEvent(feature: any) {
return this.http.post(`${this.baseUrl}/event`, feature);
}

View file

@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8">
<title>Frontend</title>
<title>OEDB Frontend</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">