edit form

This commit is contained in:
Tykayn 2025-10-03 13:40:08 +02:00 committed by tykayn
parent 83ef7bab6c
commit f991aee8ed
16 changed files with 1019 additions and 72 deletions

7
frontend/.env.example Normal file
View file

@ -0,0 +1,7 @@
DB_USER=cipherbliss
POSTGRES_PASSWORD=tralalahihou
CLIENT_ID=ziozioizo-sllkslk
CLIENT_SECRET=spposfdo-msmldflkds
CLIENT_AUTORIZATIONS=read_prefs
CLIENT_REDIRECT=https://oedb.cipherbliss.com/demo/traffic

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

View file

@ -1,4 +1,5 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
@ -7,6 +8,7 @@ export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes)
provideRouter(routes),
provideHttpClient()
]
};

View file

@ -1 +1,113 @@
<p>edit-form works!</p>
<form (ngSubmit)="onSubmit()" [formGroup]="form">
<div class="row">
<label>What</label>
<input class="input" type="text" formControlName="what" placeholder="ex: traffic.roadwork" />
<div class="presets under-field">
<h4>Presets</h4>
<div class="preset-groups">
@for (g of filteredGroups(); track g.category) {
<div class="group">
<div class="group-title">{{g.category}}</div>
<div class="preset-list">
@for (p of g.items; track p.key) {
<button type="button" (click)="applyPreset(p.key)" title="{{p.key}}">
<span class="emoji">{{p.emoji}}</span>
<span class="label">{{p.label}}</span>
</button>
}
</div>
</div>
}
</div>
</div>
</div>
<div class="row">
<label>Label</label>
<input class="input" type="text" formControlName="label" />
</div>
<div class="row">
<label>Description</label>
<textarea class="input" formControlName="description"></textarea>
</div>
<div class="row">
<label>Début</label>
<input class="input" type="datetime-local" formControlName="start" />
</div>
<div class="row">
<label>Fin</label>
<input class="input" type="datetime-local" formControlName="stop" />
<div class="muted">Durée: {{durationHuman()}}</div>
</div>
<!-- Propriétés dynamiques selon le preset sélectionné -->
@if (currentPreset(); as cp) {
<div class="row">
<div class="preset-properties">
@for (entry of presetEntries(); track entry.key) {
<div class="prop-row">
<label>{{entry.spec?.label || entry.key}}</label>
@if (entry.spec?.values; as vs) {
<select class="input" [disabled]="entry.spec?.writable === false" (change)="onPresetValueChange(entry.key, $any($event.target).value)">
@for (v of vs; track v) {
<option [value]="v" [selected]="(presetValues()[entry.key] ?? entry.spec?.default) === v">{{v}}</option>
}
</select>
} @else {
<input class="input" type="text" [disabled]="entry.spec?.writable === false" [value]="presetValues()[entry.key] ?? entry.spec?.default ?? ''" (input)="onPresetValueChange(entry.key, $any($event.target).value)"/>
}
</div>
}
</div>
</div>
}
<div class="row">
<label>Type</label>
<select class="input" formControlName="type" >
<option value="unscheduled">Unscheduled</option>
<option value="scheduled">Scheduled</option>
<option value="forecast">Forecast</option>
</select>
</div>
<div class="row">
<label>Where</label>
<input class="input" type="text" formControlName="where" />
</div>
<div class="row">
<label>Wikidata</label>
<input class="input" type="text" formControlName="wikidata" />
</div>
<div class="row">
<label>Latitude</label>
<input class="input" type="number" step="any" formControlName="lat" />
</div>
<div class="row">
<label>Longitude</label>
<input class="input" type="number" step="any" formControlName="lon" />
</div>
<div class="actions">
<button class="btn" type="submit">{{ featureId() ? 'Mettre à jour' : 'Créer' }}</button>
@if (featureId()) {
<button class="btn btn-ghost" type="button" (click)="onDelete()">Supprimer</button>
}
</div>
@if (status().state !== 'idle') {
<div class="status">
@if (status().state === 'saving') {
<div>{{status().message}}</div>
} @else if (status().state === 'saved') {
<div>
{{status().message}}.
<a [href]="'/demo/by-what?what=' + (status().what || form.value.what)" target="_blank">Voir d'autres évènements de ce type</a>
</div>
} @else if (status().state === 'error') {
<div>{{status().message}}</div>
}
</div>
}
</form>

View file

@ -0,0 +1,46 @@
form {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
}
.row {
display: grid;
gap: 6px;
}
.presets {
background: rgba(159, 211, 246, 0.2);
border: 1px dashed rgba(0,0,0,0.08);
border-radius: 10px;
padding: 10px;
}
.presets.under-field { margin-top: 8px; }
.preset-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.preset-list button {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid rgba(0,0,0,0.08);
background: #fff;
cursor: pointer;
}
.actions {
display: flex;
gap: 8px;
}
.preset-groups { display: grid; gap: 10px; }
.group-title { font-weight: 700; opacity: 0.8; margin-bottom: 6px; }
.group { background: #fff; border: 1px solid rgba(0,0,0,0.06); border-radius: 10px; padding: 8px; }

View file

@ -1,11 +1,298 @@
import { Component } from '@angular/core';
import { Component, EventEmitter, Input, Output, OnChanges, SimpleChanges, computed, effect, signal } from '@angular/core';
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { NgFor, NgIf } from '@angular/common';
import oedb from '../../../oedb-types';
import { OedbApi } from '../../services/oedb-api';
@Component({
selector: 'app-edit-form',
imports: [],
imports: [ReactiveFormsModule],
templateUrl: './edit-form.html',
styleUrl: './edit-form.scss'
})
export class EditForm {
export class EditForm implements OnChanges {
@Input() selected: any | null = null;
@Output() saved = new EventEmitter<any>();
@Output() created = new EventEmitter<any>();
@Output() deleted = new EventEmitter<any>();
form: FormGroup;
allPresets: Array<{ key: string, label: string, emoji: string, category: string, description?: string, durationHours?: number, properties?: Record<string, { label?: string, writable?: boolean, values?: any[], default?: any, allow_custom?: boolean, allow_empty?: boolean }> }>;
filteredGroups = computed(() => this.groupPresets(this.form.get('what')?.value || ''));
currentPreset = computed(() => {
const key = this.form.get('what')?.value || '';
return (oedb.presets.what as any)[key] || null;
});
presetEntries = computed(() => {
const p = this.currentPreset();
const props = (p && p.properties) ? p.properties as Record<string, any> : {};
return Object.keys(props).map(k => ({ key: k, spec: props[k] }));
});
presetValues = signal<Record<string, any>>({});
status = signal<{ state: 'idle' | 'saving' | 'saved' | 'error', message?: string, what?: string }>({ state: 'idle' });
featureId = signal<string | number | null>(null);
durationHuman = signal<string>('');
constructor(private fb: FormBuilder, private api: OedbApi) {
this.form = this.fb.group({
label: ['', Validators.required],
description: [''],
what: ['', Validators.required],
where: [''],
lat: ['', Validators.required],
lon: ['', Validators.required],
wikidata: [''],
featureType: ['point'],
type: ['unscheduled'],
start: [''],
stop: ['']
});
const what = oedb.presets.what as Record<string, any>;
this.allPresets = Object.keys(what).map(k => ({
key: k,
label: what[k].label || k,
emoji: what[k].emoji || '',
category: what[k].category || 'Autres',
description: what[k].description || ''
}));
// initialize default 24h window
const now = new Date();
const in24h = new Date(now.getTime() + 24 * 3600 * 1000);
this.form.patchValue({
start: this.toLocalInputValue(now),
stop: this.toLocalInputValue(in24h)
}, { emitEvent: false });
// initial fill if provided on first render
this.fillFormFromSelected();
// watch start/stop changes to update human duration
this.form.valueChanges.subscribe(v => {
const startIso = this.toIsoFromLocalInput(v.start);
const stopIso = this.toIsoFromLocalInput(v.stop);
if (startIso && stopIso) this.durationHuman.set(this.humanDuration(startIso, stopIso));
});
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['selected']) {
this.fillFormFromSelected();
}
}
private fillFormFromSelected() {
const sel = this.selected;
if (sel && sel.properties) {
const propId = sel?.properties?.id ?? sel?.properties?.uuid;
this.featureId.set((propId ?? sel.id) ?? null);
const p = sel.properties || {};
const coords = sel?.geometry?.coordinates || [];
this.form.patchValue({
label: p.label || p.name || '',
id: p.id || '',
description: p.description || '',
what: p.what || '',
"what:series": p['what:series'] || '',
where: p.where || '',
lat: coords[1] ?? '',
lon: coords[0] ?? '',
wikidata: p.wikidata || '',
featureType: 'point',
type: p.type || this.form.value.type || 'unscheduled',
start: this.toLocalInputValue(p.start || p.when || new Date()),
stop: this.toLocalInputValue(p.stop || new Date(new Date().getTime() + 24 * 3600 * 1000))
}, { emitEvent: false });
// hydrate presetValues from selected properties for known keys
const current = this.currentPreset();
const result: Record<string, any> = {};
if (current && current.properties) {
Object.keys(current.properties).forEach(key => {
if (Object.prototype.hasOwnProperty.call(p, key)) {
result[key] = p[key];
}
});
}
this.presetValues.set(result);
}
}
applyPreset(key: string) {
const what = oedb.presets.what as Record<string, any>;
const preset = what[key];
if (!preset) return;
this.form.patchValue({
what: key,
label: preset.label || this.form.value.label,
description: preset.description || this.form.value.description
});
// initialize presetValues with defaults if any
const result: Record<string, any> = {};
const props = preset.properties || {};
Object.keys(props).forEach(k => {
if (Object.prototype.hasOwnProperty.call(props[k], 'default')) result[k] = props[k].default;
});
this.presetValues.set(result);
// adjust stop based on preset duration
const startIso = this.toIsoFromLocalInput(this.form.value.start);
if (typeof preset.durationHours === 'number' && startIso) {
const start = new Date(startIso);
const stop = new Date(start.getTime() + preset.durationHours * 3600 * 1000);
this.form.patchValue({ stop: this.toLocalInputValue(stop) }, { emitEvent: true });
}
}
private groupPresets(query: string): Array<{ category: string, items: Array<{ key: string, label: string, emoji: string }> }> {
const q = String(query || '').trim().toLowerCase();
const matches = (p: typeof this.allPresets[number]) => {
if (!q) return true;
return (
p.key.toLowerCase().includes(q) ||
(p.label || '').toLowerCase().includes(q) ||
(p.description || '').toLowerCase().includes(q) ||
(p.category || '').toLowerCase().includes(q)
);
};
const grouped: Record<string, Array<{ key: string, label: string, emoji: string }>> = {};
for (const p of this.allPresets) {
if (!matches(p)) continue;
const cat = p.category || 'Autres';
if (!grouped[cat]) grouped[cat] = [];
grouped[cat].push({ key: p.key, label: p.label, emoji: p.emoji });
}
return Object.keys(grouped)
.sort((a, b) => a.localeCompare(b))
.map(cat => ({ category: cat, items: grouped[cat].sort((a, b) => a.label.localeCompare(b.label)) }));
}
onSubmit() {
const val = this.form.value;
const feature: any = {
type: 'Feature',
properties: {
label: val.label,
description: val.description,
what: val.what,
where: val.where,
wikidata: val.wikidata,
type: val.type,
start: this.toIsoFromLocalInput(val.start),
stop: this.toIsoFromLocalInput(val.stop)
},
geometry: {
type: 'Point',
coordinates: [Number(val.lon), Number(val.lat)]
}
};
// Apply default duration from preset when creating a new event
const preset = (oedb.presets.what as any)[val.what];
if ((!this.featureId()) && preset && typeof preset.durationHours === 'number') {
// already set from form; ensure consistency if empty
if (!feature.properties.start || !feature.properties.stop) {
const start = new Date();
const stop = new Date(start.getTime() + preset.durationHours * 3600 * 1000);
feature.properties.start = start.toISOString();
feature.properties.stop = stop.toISOString();
}
}
const id = this.featureId();
// merge dynamic preset properties
const extra = this.presetValues();
Object.keys(extra || {}).forEach(k => {
feature.properties[k] = extra[k];
});
this.status.set({ state: 'saving', what: val.what, message: 'Envoi en cours…' });
if (id !== null && id !== undefined && id !== '') {
this.api.updateEvent(id, feature).subscribe({
next: (res) => {
this.status.set({ state: 'saved', what: val.what, message: 'Évènement mis à jour' });
this.saved.emit(res);
},
error: (err) => {
this.status.set({ state: 'error', what: val.what, message: 'Erreur lors de la mise à jour' });
console.error(err);
}
});
} else {
this.api.createEvent(feature).subscribe({
next: (res) => {
this.status.set({ state: 'saved', what: val.what, message: 'Évènement créé' });
this.created.emit(res);
},
error: (err) => {
this.status.set({ state: 'error', what: val.what, message: 'Erreur lors de la création' });
console.error(err);
}
});
}
}
onPresetValueChange(key: string, value: any) {
const current = { ...this.presetValues() };
current[key] = value;
this.presetValues.set(current);
}
onDelete() {
const id = this.featureId();
if (id === null || id === undefined || id === '') return;
this.status.set({ state: 'saving', what: this.form.value.what, message: 'Suppression…' });
this.api.deleteEvent(id).subscribe({
next: (res) => {
this.status.set({ state: 'saved', what: this.form.value.what, message: 'Évènement supprimé' });
this.deleted.emit(res);
},
error: (err) => {
this.status.set({ state: 'error', what: this.form.value.what, message: 'Erreur lors de la suppression' });
console.error(err);
}
});
}
private toLocalInputValue(d: string | Date): string {
const date = (typeof d === 'string') ? new Date(d) : d;
if (Number.isNaN(date.getTime())) return '';
const pad = (n: number) => n.toString().padStart(2, '0');
const y = date.getFullYear();
const m = pad(date.getMonth() + 1);
const da = pad(date.getDate());
const h = pad(date.getHours());
const mi = pad(date.getMinutes());
return `${y}-${m}-${da}T${h}:${mi}`;
}
private toIsoFromLocalInput(s?: string): string | null {
if (!s) return null;
// Treat input as local time and convert to ISO
const m = /^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2})$/.exec(s);
if (!m) return null;
const date = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4]), Number(m[5]), 0, 0);
return date.toISOString();
}
private humanDuration(startIso: string, stopIso: string): string {
const a = new Date(startIso).getTime();
const b = new Date(stopIso).getTime();
if (!Number.isFinite(a) || !Number.isFinite(b) || b <= a) return '';
const ms = b - a;
const hours = Math.floor(ms / 3600000);
const days = Math.floor(hours / 24);
const h = hours % 24;
if (days > 0 && h > 0) return `${days} j ${h} h`;
if (days > 0) return `${days} j`;
return `${h} h`;
}
}

View file

@ -1 +1,7 @@
<p>all-events works!</p>
<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>
</div>

View file

@ -1,4 +1,5 @@
import { Component } from '@angular/core';
import { Component, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output, ViewChild } from '@angular/core';
import oedb_what_categories from '../../../oedb-types';
@Component({
selector: 'app-all-events',
@ -7,5 +8,270 @@ import { Component } from '@angular/core';
styleUrl: './all-events.scss'
})
export class AllEvents {
@Input() features: Array<any> = [];
@Output() select = new EventEmitter<any>();
@Output() pickCoords = new EventEmitter<[number, number]>();
@ViewChild('mapContainer', { static: true }) mapContainer!: ElementRef<HTMLDivElement>;
private map: any;
private markers: any[] = [];
private pickedMarker: any | null = null;
async ngOnInit() {
await this.ensureMapLibre();
this.initMap();
this.renderFeatures();
}
ngOnDestroy(): void {
this.markers.forEach(m => m.remove && m.remove());
this.markers = [];
if (this.map && this.map.remove) this.map.remove();
}
ngOnChanges(): void {
this.renderFeatures();
}
private ensureMapLibre(): Promise<void> {
return new Promise(resolve => {
if ((window as any).maplibregl) return resolve();
const css = document.createElement('link');
css.rel = 'stylesheet';
css.href = 'https://unpkg.com/maplibre-gl@3.6.0/dist/maplibre-gl.css';
document.head.appendChild(css);
const s = document.createElement('script');
s.src = 'https://unpkg.com/maplibre-gl@3.6.0/dist/maplibre-gl.js';
s.onload = () => resolve();
document.body.appendChild(s);
});
}
private initMap() {
const maplibregl = (window as any).maplibregl;
this.map = new maplibregl.Map({
container: this.mapContainer.nativeElement,
style: 'https://tiles.openfreemap.org/styles/liberty',
center: [2.3522, 48.8566],
zoom: 5
});
this.map.addControl(new maplibregl.NavigationControl());
this.map.addControl(new maplibregl.GeolocateControl({ positionOptions: { enableHighAccuracy: true }, trackUserLocation: true }));
this.map.on('click', (e: any) => {
const coords: [number, number] = [e.lngLat.lng, e.lngLat.lat];
this.showPickedMarker(coords);
this.pickCoords.emit(coords);
});
}
private getEmojiForWhat(what: string): string {
try {
// if what is exact key
const preset: any = (oedb_what_categories as any).presets.what as Record<string, any>;
if (preset && preset[what] && preset[what].emoji) return preset[what].emoji;
const family = what?.split('.')[0] || '';
if (preset && preset[family] && preset[family].emoji) return preset[family].emoji;
} catch {}
return '📍';
}
private getImageForWhat(what: string): string | null {
try {
const preset: any = (oedb_what_categories as any).presets.what as Record<string, any>;
if (preset && preset[what] && preset[what].image) return preset[what].image;
const family = what?.split('.')[0] || '';
if (preset && preset[family] && preset[family].image) return preset[family].image;
} catch {}
return null;
}
private showPickedMarker(coords: [number, number]) {
const maplibregl = (window as any).maplibregl;
const el = document.createElement('div');
el.style.width = '20px';
el.style.height = '20px';
el.style.borderRadius = '50%';
el.style.background = '#2196f3';
el.style.border = '2px solid white';
el.style.boxShadow = '0 0 0 2px rgba(33,150,243,0.3)';
if (this.pickedMarker && this.pickedMarker.remove) {
this.pickedMarker.remove();
}
this.pickedMarker = new maplibregl.Marker({ element: el }).setLngLat(coords).addTo(this.map);
}
async searchPlace(query: string) {
const q = (query || '').trim();
if (!q) return;
try {
const resp = await fetch(`https://nominatim.openstreetmap.org/search?format=geojson&q=${encodeURIComponent(q)}`);
const data = await resp.json();
const f = data?.features?.[0];
const coords = f?.geometry?.type === 'Point' ? f.geometry.coordinates : f?.bbox;
if (Array.isArray(coords)) {
if (coords.length === 2) {
this.map.flyTo({ center: coords, zoom: 14 });
this.showPickedMarker(coords as [number, number]);
this.pickCoords.emit(coords as [number, number]);
} else if (coords.length === 4) {
const maplibregl = (window as any).maplibregl;
const bounds = new maplibregl.LngLatBounds([coords[0], coords[1]], [coords[2], coords[3]]);
this.map.fitBounds(bounds, { padding: 40 });
}
}
} catch {}
}
private renderFeatures() {
if (!this.map || !Array.isArray(this.features)) return;
// clear existing markers
this.markers.forEach(m => m.remove && m.remove());
this.markers = [];
const maplibregl = (window as any).maplibregl;
const bounds = new maplibregl.LngLatBounds();
this.features.forEach(f => {
const coords = f?.geometry?.coordinates;
if (!coords || !Array.isArray(coords)) return;
const p = f.properties || {};
const el = this.buildMarkerElement(p);
el.style.cursor = 'pointer';
el.addEventListener('click', () => {
this.select.emit({
id: (p && (p.id ?? p.uuid)) ?? f?.id,
properties: p,
geometry: { type: 'Point', coordinates: coords }
});
});
const popupHtml = this.buildPopupHtml(p, (p && (p.id ?? p.uuid)) ?? f?.id);
const marker = new maplibregl.Marker({ element: el })
.setLngLat(coords)
.setPopup(new maplibregl.Popup({ offset: 12 }).setHTML(popupHtml))
.addTo(this.map);
const popup = marker.getPopup && marker.getPopup();
if (popup && popup.on) {
popup.on('open', () => {
const rawId = (p && (p.id ?? p.uuid)) ?? f?.id;
const targetId = typeof rawId !== 'undefined' ? String(rawId) : `${coords[0]},${coords[1]}`;
const elTitle = document.querySelector(`[data-feature-id="${CSS.escape(targetId)}"]`);
if (elTitle) {
elTitle.addEventListener('click', (ev: Event) => {
ev.preventDefault();
this.select.emit({
id: (p && (p.id ?? p.uuid)) ?? f?.id,
properties: p,
geometry: { type: 'Point', coordinates: coords }
});
}, { once: true });
}
});
}
this.markers.push(marker);
bounds.extend(coords);
});
if (!bounds.isEmpty()) {
this.map.fitBounds(bounds, { padding: 40, maxZoom: 12 });
}
}
private buildMarkerElement(props: any): HTMLDivElement {
const container = document.createElement('div');
container.style.fontSize = '20px';
container.style.lineHeight = '1';
container.style.display = 'flex';
container.style.alignItems = 'center';
container.style.justifyContent = 'center';
const htmlCandidate = this.findMarkerHtml(props);
if (htmlCandidate) {
const safe = this.sanitizeHtml(htmlCandidate);
container.innerHTML = safe;
return container;
}
const what = props?.what || '';
const image = this.getImageForWhat(what);
if (image) {
const img = document.createElement('img');
img.src = image;
img.alt = what || 'marker';
img.style.width = '24px';
img.style.height = '24px';
img.style.objectFit = 'contain';
container.appendChild(img);
return container;
}
const emoji = this.getEmojiForWhat(what);
container.textContent = emoji;
return container;
}
private findMarkerHtml(props: any): string | null {
const keysToCheck = ['marker_html', 'icon_html', 'html', 'marker', 'icon'];
for (const key of keysToCheck) {
const value = props?.[key];
if (typeof value === 'string' && value.includes('<')) return value;
}
return null;
}
private sanitizeHtml(html: string): string {
const temp = document.createElement('div');
temp.innerHTML = html;
const walk = (node: Element) => {
// Remove script and style tags entirely
if (node.tagName === 'SCRIPT' || node.tagName === 'STYLE') {
node.remove();
return;
}
// Strip event handlers and javascript: URLs
for (const attr of Array.from(node.attributes)) {
const name = attr.name.toLowerCase();
const value = attr.value || '';
if (name.startsWith('on')) {
node.removeAttribute(attr.name);
continue;
}
if ((name === 'href' || name === 'src') && /^\s*javascript:/i.test(value)) {
node.removeAttribute(attr.name);
continue;
}
if (node.tagName === 'A' && name === 'href' && !/^(https?:|#|\/)/i.test(value)) {
node.removeAttribute(attr.name);
continue;
}
}
// Recurse children
for (const child of Array.from(node.children)) walk(child as Element);
};
for (const child of Array.from(temp.children)) walk(child as Element);
return temp.innerHTML;
}
private buildPopupHtml(props: any, id?: any): string {
const title = this.escapeHtml(String(props?.name || props?.label || props?.what || 'évènement'));
const titleId = typeof id !== 'undefined' ? String(id) : '';
const rows = Object.keys(props || {}).sort().map(k => {
const v = props[k];
const value = typeof v === 'object' ? `<pre>${this.escapeHtml(JSON.stringify(v, null, 2))}</pre>` : this.escapeHtml(String(v));
return `<tr><td style="font-weight:bold;vertical-align:top;padding:2px 6px;">${this.escapeHtml(k)}</td><td style="padding:2px 6px;">${value}</td></tr>`;
}).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>
</div>`;
return `<div style="max-width:320px">${clickable}<table style="border-collapse:collapse;width:100%">${rows}</table></div>`;
}
private escapeHtml(s: string): string {
return s.replace(/[&<>"]+/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c] as string));
}
}

View file

@ -1,13 +1,21 @@
<p>home works!</p>
<div class="aside">
<div class="layout">
<div class="aside">
<div class="toolbar">
<strong>OpenEventDatabase</strong>
<span class="muted">{{features.length}} évènements</span>
</div>
<div class="filters">
<label>Filtre rapide</label>
<input class="input" type="text" placeholder="Rechercher...">
</div>
<hr>
<app-menu></app-menu>
</div>
<div class="main">
main part
<br>
<div id="map">
(map)
<hr>
<app-edit-form [selected]="selected" (saved)="onSaved($event)" (created)="onCreated($event)" (deleted)="onDeleted($event)"></app-edit-form>
</div>
<div class="main">
<div class="map">
<app-all-events [features]="features" (select)="onSelect($event)" (pickCoords)="onPickCoords($event)"></app-all-events>
</div>
</div>
</div>

View file

@ -1,28 +1,37 @@
:host{
header{
background: #00acc1;
position: fixed;
top: 0 ;
width: 100vw;
min-height: 1rem;
}
main{
display: flex;
flex-direction: row;
justify-content: start;
align-content: center;
}
.aside{
background: #fff8f8;
box-shadow: 0 0 10px rgba(0,0,0,0.1);
width : 0;
&.expanded{
width: 300px;
padding: 10px;
}
}
#map{
width: 100%;
height: 100vh;
}
:host {
display: block;
}
.layout {
display: grid;
grid-template-columns: 340px 1fr;
grid-template-rows: 100vh;
gap: 0;
}
.aside {
background: #ffffff;
border-right: 1px solid rgba(0,0,0,0.06);
box-shadow: 2px 0 12px rgba(0,0,0,0.03);
padding: 16px;
overflow: auto;
}
.main {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
}
.map {
flex: 1 1 auto;
min-height: 0;
}

View file

@ -1,11 +1,15 @@
import { Component, inject } from '@angular/core';
import {Menu} from './menu/menu';
import { AllEvents } from '../../maps/all-events/all-events';
import { EditForm } from '../../forms/edit-form/edit-form';
import { OedbApi } from '../../services/oedb-api';
@Component({
selector: 'app-home',
imports: [
Menu
Menu,
AllEvents,
EditForm
],
templateUrl: './home.html',
styleUrl: './home.scss'
@ -13,10 +17,55 @@ import { OedbApi } from '../../services/oedb-api';
export class Home {
OedbApi = inject(OedbApi);
features: Array<any> = [];
selected: any | null = null;
constructor() {
this.OedbApi.getEvents({}).subscribe((events) => {
console.log(events);
this.OedbApi.getEvents({ when: 'now', limit: 500 }).subscribe((events: any) => {
this.features = Array.isArray(events?.features) ? events.features : [];
});
}
onSelect(feature: any) {
this.selected = feature;
}
onPickCoords(coords: [number, number]) {
// Autofill lat/lon in the form selection or prepare a new feature shell
const [lon, lat] = coords;
if (this.selected && this.selected.properties) {
this.selected = {
...this.selected,
geometry: { type: 'Point', coordinates: [lon, lat] }
};
} else {
this.selected = {
id: null,
properties: { label: '', description: '', what: '', where: '' },
geometry: { type: 'Point', coordinates: [lon, lat] }
};
}
}
onSaved(_res: any) {
// refresh list after update
this.OedbApi.getEvents({ when: 'now', limit: 500 }).subscribe((events: any) => {
this.features = Array.isArray(events?.features) ? events.features : [];
});
}
onCreated(_res: any) {
// refresh and clear selection after create
this.selected = null;
this.OedbApi.getEvents({ when: 'now', limit: 500 }).subscribe((events: any) => {
this.features = Array.isArray(events?.features) ? events.features : [];
});
}
onDeleted(_res: any) {
this.selected = null;
this.OedbApi.getEvents({ when: 'now', limit: 500 }).subscribe((events: any) => {
this.features = Array.isArray(events?.features) ? events.features : [];
});
}
}

View file

@ -3,7 +3,7 @@
<a href="/demo/stats">stats</a>
<a href="https://source.cipherbliss.com/tykayn/oedb-backend">sources</a>
(editor
(editor)
<div id="editor_form">
<div id="search_input">
<input type="text" value="" placeholder="Rechercher une catégorie d'évènement">

View file

@ -1,15 +1,28 @@
:host {
#what_categories{
.cateogry {
background: #f8f8f8;
border-radius: 4px;
padding: 10px;
margin-bottom: 10px;
width: 300px;
display: block;
border: 1px solid #ddd;
}
}
}
#what_categories {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
}
.category {
background: #ffffff;
border-radius: 10px;
padding: 10px;
border: 1px solid rgba(0,0,0,0.08);
display: grid;
grid-template-columns: 28px 1fr;
gap: 10px;
align-items: center;
}
.emoji {
font-size: 20px;
}
.label {
font-weight: 600;
}

View file

@ -13,4 +13,16 @@ export class OedbApi {
getEvents(params: any) {
return this.http.get(`${this.baseUrl}/event`, { params });
}
createEvent(feature: any) {
return this.http.post(`${this.baseUrl}/event`, feature);
}
updateEvent(id: string | number, feature: any) {
return this.http.put(`${this.baseUrl}/event/${id}`, feature);
}
deleteEvent(id: string | number) {
return this.http.delete(`${this.baseUrl}/event/${id}`);
}
}

View file

@ -6,7 +6,7 @@ const oedb = {
description: 'Événement communautaire',
category: 'Communauté',
emoji: '\\o/',
duration : '1D' // 1 day
durationHours: 24
},
// Community / OSM
'community.osm.event': {
@ -65,7 +65,8 @@ const oedb = {
emoji: '☀️',
label: 'Heure d\'été',
category: 'Temps',
description: 'Passage à l\'heure d\'été'
description: 'Passage à l\'heure d\'été',
durationHours: 24
},
// Tourism
@ -81,7 +82,13 @@ const oedb = {
emoji: '💥',
label: 'Accident',
category: 'Circulation',
description: 'Accident de la circulation'
description: 'Accident de la circulation',
durationHours: 6,
properties: {
severity: { label: 'Gravité', writable: true },
lanes_closed: { label: 'Voies fermées', writable: true },
vehicles: { label: 'Nombre de véhicules', writable: true }
}
},
'traffic.incident': {
emoji: '⚠️',
@ -102,28 +109,94 @@ const oedb = {
description: 'Fermeture partielle de voie'
},
'traffic.roadwork': {
emoji: '<img src="static/cone.png" class="icone cone" />',
emoji: '',
image: 'static/cone.png',
label: 'Travaux routiers',
category: 'Circulation',
description: 'Travaux sur la chaussée'
description: 'Travaux sur la chaussée',
durationHours: 72,
properties: {
contractor: { label: 'Entreprise', writable: true },
reason: { label: 'Raison', writable: true },
lanes_affected: { label: 'Voies impactées', writable: true }
}
},
'wildlife': {
emoji: '🦌',
label: 'Animal',
category: 'Vie sauvage',
description: 'Détection d\'animaux'
description: 'Détection d\'animaux',
properties: {
detection_by: {
values: ['human', 'camera'],
default: 'human',
allow_empty: true,
allow_custom: true,
label: 'Détection par',
description: 'Comment l\'animal a été détecté',
},
animal: {
values: ['deer', 'bear', 'fox', 'wolf', 'rabbit', 'bird', 'fish', 'insect', 'other'],
default: 'deer',
allow_empty: true,
allow_custom: true,
label: 'Animal',
description: 'L\'animal détecté',
},
}
},
'traffic.mammoth': {
emoji: '🦣',
label: 'Mammouth laineux wohoooo! (évènement de test)',
category: 'Obstacle',
description: 'Un mammouth laineux bloque la route'
description: 'Un mammouth laineux bloque la route (évènement de test)',
durationHours: 48,
properties: {
test: true,
weight: 1000
}
},
'hazard.piranha': {
emoji: '🐟',
label: 'Piranha dans la piscine',
label: 'Piranha dans la piscine (évènement de test)',
category: 'Danger',
description: 'Des pirana attaquent dans cette piscine'
description: 'Des pirana attaquent dans cette piscine (évènement de test)',
durationHours: 48
},
// Météo étendue
'weather.storm': {
emoji: '🌪️',
label: 'Tempête',
category: 'Météo',
description: 'Tempête (vent fort)',
durationHours: 48,
properties: {
wind_speed: { label: 'Vent moyen (km/h)', writable: true },
wind_gust: { label: 'Rafales (km/h)', writable: true },
severity: { label: 'Sévérité', writable: true }
}
},
'weather.thunder': {
emoji: '⚡',
label: 'Éclairs / orage',
category: 'Météo',
description: 'Activité orageuse',
durationHours: 12,
properties: {
lightning_count: { label: 'Nombre déclairs', writable: true }
}
},
'weather.earthquake': {
emoji: '🌎',
label: 'Tremblement de terre',
category: 'Météo',
description: 'Séisme',
durationHours: 6,
properties: {
magnitude: { label: 'Magnitude (Mw)', writable: true },
depth_km: { label: 'Profondeur (km)', writable: true }
}
}
// ici ajouter d'autres catégories d'évènements à suggérer
}

View file

@ -1 +1,58 @@
/* You can add global styles to this file, and also import other style files */
/* Theme variables */
$color-blue: #9fd3f6; /* pastel blue */
$color-green: #b9e4c9; /* pastel green */
$color-bg: #f7fafb;
$color-surface: #ffffff;
$color-text: #22303a;
$color-muted: #6b7b86;
$border-radius: 10px;
$shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.06);
$shadow-md: 0 6px 18px rgba(0, 0, 0, 0.08);
$spacing: 12px;
html, body {
height: 100%;
margin: 0;
padding: 0;
background: $color-bg;
color: $color-text;
font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Arial, sans-serif;
}
app-root, app-home {
display: block;
min-height: 100vh;
}
/* Generic UI elements */
.btn {
appearance: none;
border: none;
border-radius: $border-radius;
padding: 10px 14px;
cursor: pointer;
background: linear-gradient(135deg, $color-blue, $color-green);
color: $color-text;
font-weight: 600;
box-shadow: $shadow-sm;
transition: transform 0.04s ease, box-shadow 0.2s ease, opacity 0.2s;
&:hover { box-shadow: $shadow-md; }
&:active { transform: translateY(1px); }
}
.btn-ghost {
background: $color-surface;
border: 1px solid rgba(0,0,0,0.08);
}
.input, .select, textarea {
width: 100%;
padding: 10px 12px;
border-radius: $border-radius;
border: 1px solid rgba(0,0,0,0.12);
background: $color-surface;
color: $color-text;
box-shadow: inset 0 1px 0 rgba(0,0,0,0.02);
}
label { font-size: 0.85rem; color: $color-muted; }