diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..2d52cfd --- /dev/null +++ b/frontend/.env.example @@ -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 diff --git a/frontend/public/static/cone.png b/frontend/public/static/cone.png new file mode 100644 index 0000000..b10b419 Binary files /dev/null and b/frontend/public/static/cone.png differ diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts index d953f4c..8818bca 100644 --- a/frontend/src/app/app.config.ts +++ b/frontend/src/app/app.config.ts @@ -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() ] }; diff --git a/frontend/src/app/forms/edit-form/edit-form.html b/frontend/src/app/forms/edit-form/edit-form.html index d229b21..021eb5f 100644 --- a/frontend/src/app/forms/edit-form/edit-form.html +++ b/frontend/src/app/forms/edit-form/edit-form.html @@ -1 +1,113 @@ -

edit-form works!

+
+
+ + +
+

Presets

+
+ @for (g of filteredGroups(); track g.category) { +
+
{{g.category}}
+
+ @for (p of g.items; track p.key) { + + } +
+
+ } +
+
+
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
Durée: {{durationHuman()}}
+
+ + + @if (currentPreset(); as cp) { +
+
+ @for (entry of presetEntries(); track entry.key) { +
+ + @if (entry.spec?.values; as vs) { + + } @else { + + } +
+ } +
+
+ } + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + + +
+ + @if (featureId()) { + + } +
+ + @if (status().state !== 'idle') { +
+ @if (status().state === 'saving') { +
{{status().message}}
+ } @else if (status().state === 'saved') { +
+ {{status().message}}. + Voir d'autres évènements de ce type +
+ } @else if (status().state === 'error') { +
{{status().message}}
+ } +
+ } +
diff --git a/frontend/src/app/forms/edit-form/edit-form.scss b/frontend/src/app/forms/edit-form/edit-form.scss index e69de29..cbf8e04 100644 --- a/frontend/src/app/forms/edit-form/edit-form.scss +++ b/frontend/src/app/forms/edit-form/edit-form.scss @@ -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; } + diff --git a/frontend/src/app/forms/edit-form/edit-form.ts b/frontend/src/app/forms/edit-form/edit-form.ts index 670ed6b..dec440d 100644 --- a/frontend/src/app/forms/edit-form/edit-form.ts +++ b/frontend/src/app/forms/edit-form/edit-form.ts @@ -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(); + @Output() created = new EventEmitter(); + @Output() deleted = new EventEmitter(); + form: FormGroup; + allPresets: Array<{ key: string, label: string, emoji: string, category: string, description?: string, durationHours?: number, properties?: Record }>; + 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 : {}; + return Object.keys(props).map(k => ({ key: k, spec: props[k] })); + }); + + presetValues = signal>({}); + + status = signal<{ state: 'idle' | 'saving' | 'saved' | 'error', message?: string, what?: string }>({ state: 'idle' }); + + featureId = signal(null); + durationHuman = signal(''); + + 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; + 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 = {}; + 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; + 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 = {}; + 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> = {}; + 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`; + } } diff --git a/frontend/src/app/maps/all-events/all-events.html b/frontend/src/app/maps/all-events/all-events.html index b4c39ac..fe2fb5b 100644 --- a/frontend/src/app/maps/all-events/all-events.html +++ b/frontend/src/app/maps/all-events/all-events.html @@ -1 +1,7 @@ -

all-events works!

+
+
+ +
diff --git a/frontend/src/app/maps/all-events/all-events.ts b/frontend/src/app/maps/all-events/all-events.ts index 6aa689d..ab04eea 100644 --- a/frontend/src/app/maps/all-events/all-events.ts +++ b/frontend/src/app/maps/all-events/all-events.ts @@ -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 = []; + @Output() select = new EventEmitter(); + @Output() pickCoords = new EventEmitter<[number, number]>(); + @ViewChild('mapContainer', { static: true }) mapContainer!: ElementRef; + + 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 { + 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; + 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; + 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' ? `
${this.escapeHtml(JSON.stringify(v, null, 2))}
` : this.escapeHtml(String(v)); + return `${this.escapeHtml(k)}${value}`; + }).join(''); + const clickable = `
+ ${title} +
`; + return `
${clickable}${rows}
`; + } + + private escapeHtml(s: string): string { + return s.replace(/[&<>"]+/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c] as string)); + } } diff --git a/frontend/src/app/pages/home/home.html b/frontend/src/app/pages/home/home.html index 9af8203..5c75da1 100644 --- a/frontend/src/app/pages/home/home.html +++ b/frontend/src/app/pages/home/home.html @@ -1,13 +1,21 @@ -

home works!

-
- - -
-
- main part -
-
- - (map) +
+
+
+ OpenEventDatabase + {{features.length}} évènements +
+
+ + +
+
+ +
+ +
+
+
+ +
diff --git a/frontend/src/app/pages/home/home.scss b/frontend/src/app/pages/home/home.scss index 803ba6f..937b5a3 100644 --- a/frontend/src/app/pages/home/home.scss +++ b/frontend/src/app/pages/home/home.scss @@ -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; } diff --git a/frontend/src/app/pages/home/home.ts b/frontend/src/app/pages/home/home.ts index 8e7d855..132a08d 100644 --- a/frontend/src/app/pages/home/home.ts +++ b/frontend/src/app/pages/home/home.ts @@ -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 = []; + 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 : []; }); } } diff --git a/frontend/src/app/pages/home/menu/menu.html b/frontend/src/app/pages/home/menu/menu.html index c7611ff..f4c3146 100644 --- a/frontend/src/app/pages/home/menu/menu.html +++ b/frontend/src/app/pages/home/menu/menu.html @@ -3,7 +3,7 @@ stats sources - (editor + (editor)
diff --git a/frontend/src/app/pages/home/menu/menu.scss b/frontend/src/app/pages/home/menu/menu.scss index 3b1c8df..e807116 100644 --- a/frontend/src/app/pages/home/menu/menu.scss +++ b/frontend/src/app/pages/home/menu/menu.scss @@ -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; - } - } + display: block; +} + +#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; } diff --git a/frontend/src/app/services/oedb-api.ts b/frontend/src/app/services/oedb-api.ts index 78d92aa..c82afcc 100644 --- a/frontend/src/app/services/oedb-api.ts +++ b/frontend/src/app/services/oedb-api.ts @@ -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}`); + } } diff --git a/frontend/src/oedb-types.ts b/frontend/src/oedb-types.ts index ffb7cb3..928ab8b 100644 --- a/frontend/src/oedb-types.ts +++ b/frontend/src/oedb-types.ts @@ -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: '', + 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 } diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss index 90d4ee0..203e18c 100644 --- a/frontend/src/styles.scss +++ b/frontend/src/styles.scss @@ -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; }