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 { 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()
|
||||
]
|
||||
};
|
||||
|
|
|
@ -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({
|
||||
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`;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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({
|
||||
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c] as string));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,13 +1,21 @@
|
|||
<p>home works!</p>
|
||||
<div class="aside">
|
||||
|
||||
<app-menu></app-menu>
|
||||
</div>
|
||||
<div class="main">
|
||||
main part
|
||||
<br>
|
||||
<div id="map">
|
||||
|
||||
(map)
|
||||
<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>
|
||||
<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>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 : [];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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