edit form
This commit is contained in:
parent
83ef7bab6c
commit
f991aee8ed
16 changed files with 1019 additions and 72 deletions
7
frontend/.env.example
Normal file
7
frontend/.env.example
Normal 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
|
BIN
frontend/public/static/cone.png
Normal file
BIN
frontend/public/static/cone.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 171 KiB |
|
@ -1,4 +1,5 @@
|
||||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection } from '@angular/core';
|
import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection } from '@angular/core';
|
||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
|
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
|
@ -7,6 +8,7 @@ export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
provideBrowserGlobalErrorListeners(),
|
provideBrowserGlobalErrorListeners(),
|
||||||
provideZoneChangeDetection({ eventCoalescing: true }),
|
provideZoneChangeDetection({ eventCoalescing: true }),
|
||||||
provideRouter(routes)
|
provideRouter(routes),
|
||||||
|
provideHttpClient()
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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; }
|
||||||
|
|
|
@ -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({
|
@Component({
|
||||||
selector: 'app-edit-form',
|
selector: 'app-edit-form',
|
||||||
imports: [],
|
imports: [ReactiveFormsModule],
|
||||||
templateUrl: './edit-form.html',
|
templateUrl: './edit-form.html',
|
||||||
styleUrl: './edit-form.scss'
|
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`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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({
|
@Component({
|
||||||
selector: 'app-all-events',
|
selector: 'app-all-events',
|
||||||
|
@ -7,5 +8,270 @@ import { Component } from '@angular/core';
|
||||||
styleUrl: './all-events.scss'
|
styleUrl: './all-events.scss'
|
||||||
})
|
})
|
||||||
export class AllEvents {
|
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c] as string));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,13 +1,21 @@
|
||||||
<p>home works!</p>
|
<div class="layout">
|
||||||
<div class="aside">
|
<div class="aside">
|
||||||
|
<div class="toolbar">
|
||||||
<app-menu></app-menu>
|
<strong>OpenEventDatabase</strong>
|
||||||
</div>
|
<span class="muted">{{features.length}} évènements</span>
|
||||||
<div class="main">
|
</div>
|
||||||
main part
|
<div class="filters">
|
||||||
<br>
|
<label>Filtre rapide</label>
|
||||||
<div id="map">
|
<input class="input" type="text" placeholder="Rechercher...">
|
||||||
|
</div>
|
||||||
(map)
|
<hr>
|
||||||
|
<app-menu></app-menu>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,28 +1,37 @@
|
||||||
:host{
|
:host {
|
||||||
header{
|
display: block;
|
||||||
background: #00acc1;
|
}
|
||||||
position: fixed;
|
|
||||||
top: 0 ;
|
.layout {
|
||||||
width: 100vw;
|
display: grid;
|
||||||
min-height: 1rem;
|
grid-template-columns: 340px 1fr;
|
||||||
}
|
grid-template-rows: 100vh;
|
||||||
main{
|
gap: 0;
|
||||||
display: flex;
|
}
|
||||||
flex-direction: row;
|
|
||||||
justify-content: start;
|
.aside {
|
||||||
align-content: center;
|
background: #ffffff;
|
||||||
}
|
border-right: 1px solid rgba(0,0,0,0.06);
|
||||||
.aside{
|
box-shadow: 2px 0 12px rgba(0,0,0,0.03);
|
||||||
background: #fff8f8;
|
padding: 16px;
|
||||||
box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
overflow: auto;
|
||||||
width : 0;
|
}
|
||||||
&.expanded{
|
|
||||||
width: 300px;
|
.main {
|
||||||
padding: 10px;
|
display: flex;
|
||||||
}
|
flex-direction: column;
|
||||||
}
|
height: 100vh;
|
||||||
#map{
|
overflow: hidden;
|
||||||
width: 100%;
|
}
|
||||||
height: 100vh;
|
|
||||||
}
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,15 @@
|
||||||
import { Component, inject } from '@angular/core';
|
import { Component, inject } from '@angular/core';
|
||||||
import {Menu} from './menu/menu';
|
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';
|
import { OedbApi } from '../../services/oedb-api';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-home',
|
selector: 'app-home',
|
||||||
imports: [
|
imports: [
|
||||||
Menu
|
Menu,
|
||||||
|
AllEvents,
|
||||||
|
EditForm
|
||||||
],
|
],
|
||||||
templateUrl: './home.html',
|
templateUrl: './home.html',
|
||||||
styleUrl: './home.scss'
|
styleUrl: './home.scss'
|
||||||
|
@ -13,10 +17,55 @@ import { OedbApi } from '../../services/oedb-api';
|
||||||
export class Home {
|
export class Home {
|
||||||
|
|
||||||
OedbApi = inject(OedbApi);
|
OedbApi = inject(OedbApi);
|
||||||
|
features: Array<any> = [];
|
||||||
|
selected: any | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.OedbApi.getEvents({}).subscribe((events) => {
|
this.OedbApi.getEvents({ when: 'now', limit: 500 }).subscribe((events: any) => {
|
||||||
console.log(events);
|
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 : [];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
<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>
|
||||||
|
|
||||||
(editor
|
(editor)
|
||||||
<div id="editor_form">
|
<div id="editor_form">
|
||||||
<div id="search_input">
|
<div id="search_input">
|
||||||
<input type="text" value="" placeholder="Rechercher une catégorie d'évènement">
|
<input type="text" value="" placeholder="Rechercher une catégorie d'évènement">
|
||||||
|
|
|
@ -1,15 +1,28 @@
|
||||||
:host {
|
:host {
|
||||||
|
display: block;
|
||||||
#what_categories{
|
}
|
||||||
|
|
||||||
.cateogry {
|
#what_categories {
|
||||||
background: #f8f8f8;
|
display: grid;
|
||||||
border-radius: 4px;
|
grid-template-columns: 1fr;
|
||||||
padding: 10px;
|
gap: 8px;
|
||||||
margin-bottom: 10px;
|
}
|
||||||
width: 300px;
|
|
||||||
display: block;
|
.category {
|
||||||
border: 1px solid #ddd;
|
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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,4 +13,16 @@ export class OedbApi {
|
||||||
getEvents(params: any) {
|
getEvents(params: any) {
|
||||||
return this.http.get(`${this.baseUrl}/event`, { params });
|
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}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,7 +6,7 @@ const oedb = {
|
||||||
description: 'Événement communautaire',
|
description: 'Événement communautaire',
|
||||||
category: 'Communauté',
|
category: 'Communauté',
|
||||||
emoji: '\\o/',
|
emoji: '\\o/',
|
||||||
duration : '1D' // 1 day
|
durationHours: 24
|
||||||
},
|
},
|
||||||
// Community / OSM
|
// Community / OSM
|
||||||
'community.osm.event': {
|
'community.osm.event': {
|
||||||
|
@ -65,7 +65,8 @@ const oedb = {
|
||||||
emoji: '☀️',
|
emoji: '☀️',
|
||||||
label: 'Heure d\'été',
|
label: 'Heure d\'été',
|
||||||
category: 'Temps',
|
category: 'Temps',
|
||||||
description: 'Passage à l\'heure d\'été'
|
description: 'Passage à l\'heure d\'été',
|
||||||
|
durationHours: 24
|
||||||
},
|
},
|
||||||
|
|
||||||
// Tourism
|
// Tourism
|
||||||
|
@ -81,7 +82,13 @@ const oedb = {
|
||||||
emoji: '💥',
|
emoji: '💥',
|
||||||
label: 'Accident',
|
label: 'Accident',
|
||||||
category: 'Circulation',
|
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': {
|
'traffic.incident': {
|
||||||
emoji: '⚠️',
|
emoji: '⚠️',
|
||||||
|
@ -102,28 +109,94 @@ const oedb = {
|
||||||
description: 'Fermeture partielle de voie'
|
description: 'Fermeture partielle de voie'
|
||||||
},
|
},
|
||||||
'traffic.roadwork': {
|
'traffic.roadwork': {
|
||||||
emoji: '<img src="static/cone.png" class="icone cone" />',
|
emoji: '',
|
||||||
|
image: 'static/cone.png',
|
||||||
label: 'Travaux routiers',
|
label: 'Travaux routiers',
|
||||||
category: 'Circulation',
|
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': {
|
'wildlife': {
|
||||||
emoji: '🦌',
|
emoji: '🦌',
|
||||||
label: 'Animal',
|
label: 'Animal',
|
||||||
category: 'Vie sauvage',
|
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': {
|
'traffic.mammoth': {
|
||||||
emoji: '🦣',
|
emoji: '🦣',
|
||||||
label: 'Mammouth laineux wohoooo! (évènement de test)',
|
label: 'Mammouth laineux wohoooo! (évènement de test)',
|
||||||
category: 'Obstacle',
|
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': {
|
'hazard.piranha': {
|
||||||
emoji: '🐟',
|
emoji: '🐟',
|
||||||
label: 'Piranha dans la piscine',
|
label: 'Piranha dans la piscine (évènement de test)',
|
||||||
category: 'Danger',
|
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
|
// ici ajouter d'autres catégories d'évènements à suggérer
|
||||||
}
|
}
|
||||||
|
|
|
@ -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; }
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue