embed share

This commit is contained in:
Tykayn 2025-11-03 00:08:06 +01:00 committed by tykayn
parent 5d636b0027
commit f8abb4d11a
20 changed files with 1040 additions and 72 deletions

View file

@ -41,6 +41,10 @@
constructor(container, config) {
this.container = typeof container === 'string' ? document.querySelector(container) : container;
this.config = { ...defaultConfig, ...config };
// Fusionner les params si présents
if (config.params) {
this.config = { ...this.config, ...config.params };
}
this.events = [];
this.isLoading = false;
this.refreshTimer = null;
@ -207,7 +211,7 @@
if (this.config.limit) params.set('limit', this.config.limit.toString());
if (this.config.bbox) params.set('bbox', this.config.bbox);
const response = await fetch(`${this.config.apiUrl}/events?${params.toString()}`);
const response = await fetch(`${this.config.apiUrl}/event?${params.toString()}`);
const data = await response.json();
this.events = data.features || [];

View file

@ -3,6 +3,7 @@ import {Home} from './pages/home/home';
import { Agenda } from './pages/agenda/agenda';
import { Research } from './pages/research/research';
import { Embed } from './pages/embed/embed';
import { EmbedView } from './pages/embed/embed-view/embed-view';
import { BatchEdit } from './pages/batch-edit/batch-edit';
import { NouvellesCategories } from './pages/nouvelles-categories/nouvelles-categories';
import { UnlocatedEventsPage } from './pages/unlocated-events/unlocated-events';
@ -35,6 +36,10 @@ export const routes: Routes = [
path: 'embed',
component: Embed
},
{
path: 'embed/view',
component: EmbedView
},
{
path: 'stats',
component: Stats

View file

@ -32,7 +32,7 @@
</div>
<div class="row">
<label>Label</label>
<input class="input" type="text" formControlName="label" />
<input #labelInput class="input" type="text" formControlName="label" />
</div>
<div class="row">
<label>Description</label>
@ -84,9 +84,8 @@
</div>
</div>
}
<pre>
{{currentPreset() | json}}
</pre>
<!-- Debug preset caché -->
<!-- <pre>{{currentPreset() | json}}</pre> -->
<div class="row">
<label>Type</label>
@ -109,6 +108,16 @@
<label>Wikidata</label>
<input class="input" type="text" formControlName="wikidata" />
</div>
<div class="row">
<div class="location-controls">
<button type="button" class="btn btn-secondary btn-map-picker" (click)="openMapPicker()">
🗺️ Sélectionner sur la carte
</button>
@if (form.get('lat')?.value && form.get('lon')?.value) {
<span class="location-status">📍 Coordonnées définies</span>
}
</div>
</div>
<div class="row">
<label>Latitude</label>
<input class="input" type="number" step="any" formControlName="lat" />
@ -118,6 +127,41 @@
<input class="input" type="number" step="any" formControlName="lon" />
</div>
<!-- Modale de sélection de carte -->
@if (showMapPicker) {
<div class="map-picker-overlay" (click)="closeMapPicker()">
<div class="map-picker-modal" (click)="$event.stopPropagation()">
<div class="map-picker-header">
<h3>Sélectionner un lieu sur la carte</h3>
<button class="btn-close-map" (click)="closeMapPicker()">×</button>
</div>
<div class="map-picker-content">
<div #mapContainer class="map-picker-map"></div>
<div class="map-picker-info">
<p>Cliquez sur la carte pour sélectionner un lieu</p>
@if (selectedLocation) {
<div class="selected-coords">
<strong>Coordonnées sélectionnées :</strong><br>
Latitude: {{selectedLocation.lat.toFixed(6)}}<br>
Longitude: {{selectedLocation.lon.toFixed(6)}}
</div>
}
</div>
</div>
<div class="map-picker-actions">
<button type="button" class="btn btn-ghost" (click)="closeMapPicker()">Annuler</button>
<button
type="button"
class="btn btn-primary"
(click)="validateLocation()"
[disabled]="!selectedLocation">
Valider
</button>
</div>
</div>
</div>
}
<div class="actions">
@ -131,7 +175,7 @@
@if (status().state !== 'idle') {
<div class="toast-container">
<div class="toast" [class.is-info]="status().state==='saving'" [class.is-success]="status().state==='saved'" [class.is-error]="status().state==='error'">
<div class="toast" [class.is-info]="status().state==='saving'" [class.is-success]="status().state==='saved' || status().state==='created'" [class.is-error]="status().state==='error'">
@if (status().state === 'saving') {
<div>{{status().message}}</div>
} @else if (status().state === 'saved') {
@ -139,6 +183,19 @@
{{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 === 'created') {
<div class="success-message">
<strong>✅ {{status().message}}</strong>
<p>Merci d'avoir créé cet événement !</p>
<div class="success-actions">
@if (status().createdEvent) {
<button type="button" class="btn btn-primary btn-sm" (click)="viewCreatedEvent()">
Voir l'événement en détail
</button>
}
<span class="or-text">ou continuez à modifier ci-dessus</span>
</div>
</div>
} @else if (status().state === 'error') {
<div>{{status().message}}</div>
}

View file

@ -68,3 +68,170 @@ form {
transform: translateY(-1px);
}
// Styles pour le sélecteur de carte
.location-controls {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.btn-map-picker {
padding: 10px 16px;
font-size: 0.95rem;
display: inline-flex;
align-items: center;
gap: 6px;
}
.location-status {
color: #667eea;
font-size: 0.9rem;
font-weight: 500;
}
// Modale de sélection de carte
.map-picker-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
backdrop-filter: blur(2px);
}
.map-picker-modal {
background: white;
border-radius: 12px;
width: 90vw;
max-width: 900px;
max-height: 90vh;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.map-picker-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid #e9ecef;
h3 {
margin: 0;
font-size: 1.3rem;
color: #2c3e50;
}
}
.btn-close-map {
background: none;
border: none;
font-size: 2rem;
color: #6c757d;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: all 0.2s ease;
&:hover {
background: #f8f9fa;
color: #2c3e50;
}
}
.map-picker-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
.map-picker-map {
flex: 1;
min-height: 400px;
position: relative;
}
.map-picker-info {
padding: 16px 24px;
background: #f8f9fa;
border-top: 1px solid #e9ecef;
p {
margin: 0 0 12px 0;
color: #6c757d;
font-size: 0.9rem;
}
}
.selected-coords {
background: white;
padding: 12px;
border-radius: 8px;
border: 1px solid #e9ecef;
font-size: 0.9rem;
line-height: 1.6;
strong {
color: #2c3e50;
display: block;
margin-bottom: 8px;
}
}
.map-picker-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 20px 24px;
border-top: 1px solid #e9ecef;
background: #f8f9fa;
}
// Styles pour le message de succès après création
.success-message {
text-align: center;
strong {
display: block;
margin-bottom: 8px;
font-size: 1.1rem;
}
p {
margin: 0 0 16px 0;
color: #6c757d;
}
}
.success-actions {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
.btn-sm {
padding: 8px 16px;
font-size: 0.9rem;
}
}
.or-text {
color: #6c757d;
font-size: 0.85rem;
font-style: italic;
}

View file

@ -1,4 +1,4 @@
import { Component, EventEmitter, Input, Output, OnChanges, SimpleChanges, computed, effect, signal } from '@angular/core';
import { Component, EventEmitter, Input, Output, OnChanges, SimpleChanges, computed, effect, signal, ViewChild, ElementRef, AfterViewInit, OnDestroy } from '@angular/core';
import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import oedb from '../../../oedb-types';
import { OedbApi } from '../../services/oedb-api';
@ -11,13 +11,16 @@ import { JsonPipe } from '@angular/common';
templateUrl: './edit-form.html',
styleUrl: './edit-form.scss'
})
export class EditForm implements OnChanges {
export class EditForm implements OnChanges, AfterViewInit, OnDestroy {
@Input() selected: any | null = null;
@Output() saved = new EventEmitter<any>();
@Output() created = new EventEmitter<any>();
@Output() deleted = new EventEmitter<any>();
@Output() canceled = new EventEmitter<void>();
@ViewChild('labelInput', { static: false }) labelInput?: ElementRef<HTMLInputElement>;
@ViewChild('mapContainer', { static: false }) mapContainer?: ElementRef<HTMLDivElement>;
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(() => {
@ -41,11 +44,18 @@ export class EditForm implements OnChanges {
'label','description','what','where','lat','lon','noLocation','wikidata','featureType','type','start','stop'
]);
status = signal<{ state: 'idle' | 'saving' | 'saved' | 'error', message?: string, what?: string }>({ state: 'idle' });
status = signal<{ state: 'idle' | 'saving' | 'saved' | 'created' | 'error', message?: string, what?: string, createdEvent?: any }>({ state: 'idle' });
featureId = signal<string | number | null>(null);
durationHuman = signal<string>('');
// Carte pour sélection de lieu
showMapPicker = false;
map: any = null;
mapInitialized = false;
selectedLocation: { lat: number; lon: number } | null = null;
mapMarker: any = null;
constructor(private fb: FormBuilder, private api: OedbApi) {
this.form = this.fb.group({
label: ['', Validators.required],
@ -103,6 +113,8 @@ export class EditForm implements OnChanges {
this.featureId.set((propId ?? sel.id) ?? null);
const p = sel.properties || {};
const coords = sel?.geometry?.coordinates || [];
// Si on a un addMode (filterByPrefix) et pas d'id, c'est une création, mettre type=scheduled
const eventType = this._filterByPrefix && !this.featureId() ? 'scheduled' : (p.type || this.form.value.type || 'unscheduled');
this.form.patchValue({
label: p.label || p.name || '',
id: p.id || '',
@ -114,7 +126,7 @@ export class EditForm implements OnChanges {
lon: coords[0] ?? '',
wikidata: p.wikidata || '',
featureType: 'point',
type: p.type || this.form.value.type || 'unscheduled',
type: eventType,
start: this.toLocalInputValue(p.start || p.when || new Date()),
stop: this.toLocalInputValue(p.stop || new Date(new Date().getTime() + 24 * 3600 * 1000))
}, { emitEvent: false });
@ -329,11 +341,14 @@ export class EditForm implements OnChanges {
} else {
this.api.createEvent(feature).subscribe({
next: (res) => {
this.status.set({ state: 'saved', what: val.what, message: 'Évènement créé' });
this.status.set({
state: 'created',
what: val.what,
message: 'Évènement créé avec succès !',
createdEvent: res
});
this.created.emit(res);
// Quitter l'édition après succès
this.canceled.emit();
setTimeout(() => this.status.set({ state: 'idle' }), 3000);
// Ne pas quitter l'édition après création, laisser l'utilisateur voir/modifier
},
error: (err) => {
this.status.set({ state: 'error', what: val.what, message: 'Erreur lors de la création' });
@ -384,6 +399,7 @@ export class EditForm implements OnChanges {
resetPresetFilter() {
// Réinitialise le champ what et le filtre de préfixe pour afficher tous les presets
this._filterByPrefix = null;
this.presetFilterPrefix = null;
this.form.patchValue({ what: '' });
}
@ -391,11 +407,47 @@ export class EditForm implements OnChanges {
private _filterByPrefix: string | null = null;
@Input() set filterByPrefix(prefix: string | null) {
const previousPrefix = this._filterByPrefix;
this._filterByPrefix = prefix;
this.presetFilterPrefix = prefix;
// Si un préfixe est défini et que le champ what est vide, pré-remplir avec le préfixe
if (prefix && !this.form.get('what')?.value) {
this.form.patchValue({ what: prefix }, { emitEvent: false });
// Si un préfixe est défini et que le champ what est vide ou qu'on change de préfixe, appliquer le preset correspondant
if (prefix && (!this.form.get('what')?.value || previousPrefix !== prefix)) {
// Vérifier si le préfixe correspond à un preset exact
const what = oedb.presets.what as Record<string, any>;
if (what[prefix]) {
// C'est un preset exact, l'appliquer
setTimeout(() => {
// Si c'est une nouvelle création (pas d'id), mettre type=scheduled
if (!this.featureId()) {
this.form.patchValue({ type: 'scheduled' }, { emitEvent: false });
}
this.applyPreset(prefix);
// Mettre le focus sur le champ label après un court délai
setTimeout(() => {
if (this.labelInput) {
this.labelInput.nativeElement.focus();
}
}, 150);
}, 0);
} else {
// Sinon, juste pré-remplir avec le préfixe
this.form.patchValue({ what: prefix }, { emitEvent: false });
// Si c'est une nouvelle création, mettre type=scheduled
if (!this.featureId()) {
this.form.patchValue({ type: 'scheduled' }, { emitEvent: false });
}
}
}
}
ngAfterViewInit() {
// Si on est en mode add et que le champ what est déjà rempli, mettre le focus sur label
if (this._filterByPrefix && this.form.get('what')?.value) {
setTimeout(() => {
if (this.labelInput) {
this.labelInput.nativeElement.focus();
}
}, 200);
}
}
@ -403,6 +455,140 @@ export class EditForm implements OnChanges {
return this._filterByPrefix;
}
ngOnDestroy() {
if (this.map) {
this.map.remove();
}
}
openMapPicker() {
this.showMapPicker = true;
// Initialiser la carte après que la vue soit mise à jour
setTimeout(() => {
if (!this.mapInitialized && this.mapContainer) {
this.initMap();
} else if (this.mapContainer && this.map) {
// Si la carte existe déjà, restaurer le point sélectionné s'il y en a un
const currentLat = parseFloat(this.form.get('lat')?.value);
const currentLon = parseFloat(this.form.get('lon')?.value);
if (!isNaN(currentLat) && !isNaN(currentLon)) {
this.map.flyTo({ center: [currentLon, currentLat], zoom: 13 });
this.selectedLocation = { lat: currentLat, lon: currentLon };
this.updateMarker();
}
}
}, 100);
}
closeMapPicker() {
this.showMapPicker = false;
}
async initMap() {
if (!this.mapContainer || this.mapInitialized) return;
await this.ensureMapLibre();
const maplibregl = (window as any).maplibregl;
if (!maplibregl) {
console.error('MapLibre GL n\'a pas pu être chargé');
return;
}
// Récupérer les coordonnées actuelles ou utiliser Paris par défaut
const currentLat = parseFloat(this.form.get('lat')?.value);
const currentLon = parseFloat(this.form.get('lon')?.value);
const center: [number, number] =
(!isNaN(currentLat) && !isNaN(currentLon))
? [currentLon, currentLat]
: [2.3522, 48.8566]; // Paris par défaut
this.map = new maplibregl.Map({
container: this.mapContainer.nativeElement,
style: 'https://tiles.openfreemap.org/styles/liberty',
center: center,
zoom: currentLat && currentLon ? 13 : 5
});
this.map.addControl(new maplibregl.NavigationControl());
// Ajouter un marqueur initial si des coordonnées existent
if (!isNaN(currentLat) && !isNaN(currentLon)) {
this.selectedLocation = { lat: currentLat, lon: currentLon };
this.map.on('load', () => {
this.updateMarker();
});
}
// Gérer les clics sur la carte
this.map.on('click', (e: any) => {
const { lng, lat } = e.lngLat;
this.selectedLocation = { lat, lon: lng };
this.updateMarker();
});
this.mapInitialized = true;
}
updateMarker() {
if (!this.map || !this.selectedLocation) return;
const maplibregl = (window as any).maplibregl;
// Supprimer l'ancien marqueur
if (this.mapMarker) {
this.mapMarker.remove();
}
// Créer un nouveau marqueur
const el = document.createElement('div');
el.className = 'custom-marker';
el.style.width = '30px';
el.style.height = '30px';
el.style.borderRadius = '50%';
el.style.backgroundColor = '#667eea';
el.style.border = '3px solid white';
el.style.boxShadow = '0 2px 8px rgba(0,0,0,0.3)';
el.style.cursor = 'pointer';
this.mapMarker = new maplibregl.Marker({ element: el })
.setLngLat([this.selectedLocation.lon, this.selectedLocation.lat])
.addTo(this.map);
}
validateLocation() {
if (this.selectedLocation) {
this.form.patchValue({
lat: this.selectedLocation.lat.toFixed(6),
lon: this.selectedLocation.lon.toFixed(6)
});
this.closeMapPicker();
}
}
private async 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.head.appendChild(s);
});
}
viewCreatedEvent() {
const createdEvent = this.status().createdEvent;
if (createdEvent) {
const eventId = createdEvent?.id || createdEvent?.properties?.id || createdEvent?.properties?.uuid;
if (eventId) {
window.open(`/agenda?id=${eventId}`, '_blank');
}
}
}
onCancelEdit() {
this.selected = null;

View file

@ -119,8 +119,8 @@
@if (selectedEvent || (showEditForm && addMode)) {
<div class="event-edit-panel">
<div class="panel-header">
<h3>@if (selectedEvent && selectedEvent.id) { Modifier l'événement } @else { Créer un événement }</h3>
<button class="btn-close" (click)="selectedEvent = null; showEditForm = false; addMode = null">×</button>
<h3>@if (addMode) { Créer un événement } @else if (selectedEvent && selectedEvent.id) { Modifier l'événement } @else { Modifier l'événement }</h3>
<button class="btn-close" (click)="closeEditForm()">×</button>
</div>
@if (selectedEvent && selectedEvent.id) {
<div class="selected">
@ -147,7 +147,8 @@
[filterByPrefix]="addMode"
(saved)="onEventSaved()"
(created)="onEventCreated()"
(deleted)="onEventDeleted()">
(deleted)="onEventDeleted()"
(canceled)="onEditFormCanceled()">
</app-edit-form>
</div>
</div>

View file

@ -440,34 +440,34 @@
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid #e9ecef;
background: #f8f9fa;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.panel-header h3 {
margin: 0;
color: #2c3e50;
color: white;
font-size: 1.2rem;
font-weight: 600;
}
.btn-close {
background: none;
background: rgba(255, 255, 255, 0.2);
border: none;
font-size: 1.5rem;
color: #6c757d;
cursor: pointer;
padding: 5px;
color: white;
font-size: 24px;
width: 32px;
height: 32px;
border-radius: 50%;
width: 30px;
height: 30px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
&:hover {
background: #e9ecef;
color: #495057;
background: rgba(255, 255, 255, 0.3);
transform: scale(1.1);
}
}
@ -511,6 +511,78 @@
flex: 1;
padding: 20px;
overflow-y: auto;
background: white;
}
// Appliquer les couleurs de la modale au formulaire d'édition
.panel-content :deep(form) {
.row label {
color: #495057;
font-weight: 500;
}
.input {
border: 1px solid #e9ecef;
border-radius: 6px;
&:focus {
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
}
.presets {
background: rgba(102, 126, 234, 0.05);
border: 1px dashed rgba(102, 126, 234, 0.3);
border-radius: 10px;
}
.preset-list button:hover {
background-color: #667eea;
color: white;
border-color: #667eea;
}
.actions {
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
&:hover {
opacity: 0.9;
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(102, 126, 234, 0.3);
}
}
.btn-ghost {
border: 1px solid #667eea;
color: #667eea;
background: transparent;
&:hover {
background: rgba(102, 126, 234, 0.1);
}
&.btn-danger {
border-color: #dc3545;
color: #dc3545;
&:hover {
background: rgba(220, 53, 69, 0.1);
}
}
}
}
.prop-row {
background: #f8f9fa;
padding: 12px;
border-radius: 8px;
border-left: 4px solid #667eea;
margin-bottom: 12px;
}
}
// Responsive

View file

@ -23,6 +23,7 @@ interface OedbEvent {
stop?: string;
description?: string;
where?: string;
type?: string;
};
geometry?: {
type: string;
@ -106,13 +107,14 @@ export class Agenda implements OnInit, OnDestroy, AfterViewInit {
if (add) {
this.addMode = add;
this.showEditForm = true;
// Créer un événement temporaire avec le type what défini
// Créer un événement temporaire avec le type what défini et type=scheduled
this.selectedEvent = {
id: '',
properties: {
what: add,
label: '',
description: '',
type: 'scheduled',
start: new Date().toISOString(),
stop: new Date(Date.now() + 24 * 3600 * 1000).toISOString()
}
@ -466,6 +468,11 @@ export class Agenda implements OnInit, OnDestroy, AfterViewInit {
this.addMode = null;
}
// Handler pour le bouton "Quitter l'édition" du formulaire
onEditFormCanceled() {
this.closeEditForm();
}
scrollToDateInSidebar(date: Date) {
setTimeout(() => {
const dateKey = this.toDateKey(date);
@ -619,18 +626,14 @@ export class Agenda implements OnInit, OnDestroy, AfterViewInit {
const currentLimit = this.route.snapshot.queryParamMap.get('limit');
const limitValue = currentLimit ? Number(currentLimit) : this.defaultLimit;
this.loadEvents({ what: this.selectedWhatFilter, limit: limitValue });
this.selectedEvent = null;
this.showEditForm = false;
this.addMode = null;
this.closeEditForm();
}
onEventCreated() {
const currentLimit = this.route.snapshot.queryParamMap.get('limit');
const limitValue = currentLimit ? Number(currentLimit) : this.defaultLimit;
this.loadEvents({ what: this.selectedWhatFilter, limit: limitValue });
this.selectedEvent = null;
this.showEditForm = false;
this.addMode = null;
this.closeEditForm();
// Retirer le paramètre add de l'URL
this.router.navigate([], {
relativeTo: this.route,
@ -643,8 +646,7 @@ export class Agenda implements OnInit, OnDestroy, AfterViewInit {
const currentLimit = this.route.snapshot.queryParamMap.get('limit');
const limitValue = currentLimit ? Number(currentLimit) : this.defaultLimit;
this.loadEvents({ what: this.selectedWhatFilter, limit: limitValue });
this.selectedEvent = null;
this.showEditForm = false;
this.closeEditForm();
}
ngOnDestroy() {

View file

@ -137,8 +137,7 @@ export class CalendarComponent implements OnInit, OnDestroy, OnChanges {
onEventClick(event: CalendarEvent, $event: Event) {
$event.stopPropagation();
this.selectedEvent = event;
this.showEventDetails = true;
// Ne plus afficher la modale, juste émettre l'événement pour que le parent ouvre le panel de droite
this.eventClick.emit(event);
}

View file

@ -0,0 +1,51 @@
<div class="embed-view-container">
<div class="embed-header">
<h2>Événements OEDB</h2>
</div>
@if (isLoading) {
<div class="embed-loading">
<div class="spinner"></div>
<p>Chargement des événements...</p>
</div>
} @else if (error) {
<div class="embed-error">
<p>{{error}}</p>
</div>
} @else if (events.length === 0) {
<div class="embed-empty">
<p>Aucun événement trouvé</p>
</div>
} @else {
<div class="embed-events-list">
@for (event of events; track event.id) {
<div class="embed-event-item">
<div class="embed-event-header">
<h3 class="embed-event-title">
{{event.properties?.label || event.properties?.name || 'Événement sans nom'}}
</h3>
@if (event.properties?.what) {
<span class="embed-event-type">{{event.properties.what}}</span>
}
</div>
<div class="embed-event-details">
<div class="embed-event-date">
📅 {{formatDate(event.properties?.start || event.properties?.when)}}
</div>
@if (event.properties?.where) {
<div class="embed-event-location">
📍 {{event.properties.where}}
</div>
}
@if (event.properties?.description) {
<div class="embed-event-description">
{{event.properties.description}}
</div>
}
</div>
</div>
}
</div>
}
</div>

View file

@ -0,0 +1,136 @@
.embed-view-container {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #ffffff;
color: #2c3e50;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.embed-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 16px 20px;
text-align: center;
h2 {
margin: 0;
font-size: 1.2rem;
font-weight: 600;
}
}
.embed-loading,
.embed-error,
.embed-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
text-align: center;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid #e9ecef;
border-top: 4px solid #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 16px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.embed-error {
color: #e74c3c;
background: #fdf2f2;
}
.embed-empty {
color: #6c757d;
}
.embed-events-list {
flex: 1;
overflow-y: auto;
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
.embed-event-item {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 16px;
transition: all 0.2s ease;
border-left: 4px solid #667eea;
}
.embed-event-item:hover {
background: #ffffff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transform: translateX(2px);
}
.embed-event-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
gap: 12px;
}
.embed-event-title {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
color: #2c3e50;
flex: 1;
}
.embed-event-type {
background: #667eea;
color: white;
padding: 4px 10px;
border-radius: 12px;
font-size: 0.75rem;
white-space: nowrap;
}
.embed-event-details {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 0.9rem;
}
.embed-event-date,
.embed-event-location {
color: #6c757d;
}
.embed-event-description {
color: #495057;
margin-top: 8px;
line-height: 1.5;
}
/* Responsive */
@media (max-width: 768px) {
.embed-event-header {
flex-direction: column;
}
.embed-event-type {
align-self: flex-start;
}
}

View file

@ -0,0 +1,83 @@
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute } from '@angular/router';
import { OedbApi } from '../../../services/oedb-api';
interface OedbEvent {
id: string;
properties: {
label?: string;
name?: string;
what?: string;
start?: string;
when?: string;
stop?: string;
description?: string;
where?: string;
};
}
@Component({
selector: 'app-embed-view',
standalone: true,
imports: [CommonModule],
templateUrl: './embed-view.html',
styleUrl: './embed-view.scss'
})
export class EmbedView implements OnInit {
private oedbApi = inject(OedbApi);
private route = inject(ActivatedRoute);
events: OedbEvent[] = [];
isLoading = true;
error: string | null = null;
ngOnInit() {
this.route.queryParams.subscribe(params => {
this.loadEvents(params);
});
}
loadEvents(params: any) {
this.isLoading = true;
this.error = null;
const apiParams: any = {
when: "NEXT365DAYS",
limit: params.limit ? Number(params.limit) : 50
};
if (params.what) apiParams.what = params.what;
if (params.start) apiParams.start = params.start;
if (params.end) apiParams.end = params.end;
this.oedbApi.getEvents(apiParams).subscribe({
next: (response: any) => {
this.events = Array.isArray(response?.features) ? response.features : [];
this.isLoading = false;
},
error: (err) => {
console.error('Error loading events:', err);
this.error = 'Erreur lors du chargement des événements';
this.isLoading = false;
}
});
}
formatDate(dateString: string | undefined): string {
if (!dateString) return '—';
try {
const date = new Date(dateString);
return date.toLocaleDateString('fr-FR', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
} catch {
return dateString;
}
}
}

View file

@ -116,6 +116,17 @@
<option value="dark">Sombre</option>
</select>
</div>
<div class="form-group">
<label for="embedType">Type d'intégration :</label>
<select
id="embedType"
[(ngModel)]="config().embedType"
(ngModelChange)="updateConfig()">
<option value="iframe">Iframe (recommandé pour intégration simple)</option>
<option value="script">Script JavaScript (plus flexible)</option>
</select>
</div>
</div>
<div class="code-output">

View file

@ -12,6 +12,7 @@ interface EmbedConfig {
width: string;
height: string;
theme: string;
embedType: 'script' | 'iframe';
}
@Component({
@ -23,14 +24,15 @@ interface EmbedConfig {
})
export class Embed {
config = signal<EmbedConfig>({
apiUrl: 'https://api.openenventdatabase.org',
apiUrl: 'https://api.openeventdatabase.org',
what: 'culture',
start: '',
end: '',
limit: 50,
width: '100%',
height: '400px',
theme: 'light'
theme: 'light',
embedType: 'iframe'
});
generatedCode = signal<string>('');
@ -49,29 +51,46 @@ export class Embed {
if (config.what) params.set('what', config.what);
if (config.start) params.set('start', config.start);
if (config.end) params.set('end', config.end);
if (config.limit) params.set('limit', config.limit.toString());
if (config.limit) params.set('limit', config.limit.toString());
const queryString = params.toString();
const scriptUrl = `${window.location.origin}/embed.js`;
return `<!-- OpenEventDatabase Embed start-->
if (config.embedType === 'iframe') {
// Générer du code iframe qui pointe vers une page embed spéciale
const embedUrl = `${window.location.origin}/embed/view?${queryString}`;
return `<!-- OpenEventDatabase Embed (iframe) -->
<iframe
src="${embedUrl}"
width="${config.width}"
height="${config.height}"
frameborder="0"
scrolling="auto"
style="border: 1px solid #ddd; border-radius: 8px;">
</iframe>
<!-- OpenEventDatabase Embed end -->`;
} else {
// Code script (ancienne méthode)
const scriptUrl = `${window.location.origin}/embed.js`;
const paramsObj = queryString ?
queryString.split('&').map(param => {
const [key, value] = param.split('=');
return ` '${key}': '${decodeURIComponent(value || '')}'`;
}).join(',\n') : '';
const paramsCode = paramsObj ? ` params: {\n${paramsObj}\n },\n` : '';
return `<!-- OpenEventDatabase Embed (script) -->
<div id="oedb-events" style="width: ${config.width}; height: ${config.height}; border: 1px solid #ddd; border-radius: 8px; overflow: hidden;"></div>
<script src="${scriptUrl}"></script>
<script>
OEDBEmbed.init({
container: '#oedb-events',
apiUrl: '${config.apiUrl}',
params: {
${queryString ? queryString.split('&').map(param => `'${param.split('=')[0]}': '${param.split('=')[1]}'`).join(',\n ') : ''}
},
theme: '${config.theme}'
${paramsCode} theme: '${config.theme}'
});
</script>
<!--OpenEventDatabase Embed end-->
`;
<!-- OpenEventDatabase Embed end -->`;
}
}
copyToClipboard() {
@ -93,19 +112,37 @@ export class Embed {
<html>
<head>
<title>Aperçu OEDB Embed</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body { margin: 0; padding: 20px; font-family: Arial, sans-serif; }
.preview-container { max-width: 100%; }
.preview-container { max-width: 100%; margin-top: 20px; }
h2 { color: #2c3e50; }
</style>
</head>
<body>
<h2>Aperçu de l'intégration</h2>
<p>Voici comment l'intégration apparaîtra sur votre site :</p>
<div class="preview-container">
${code}
</div>
${config.embedType === 'script' ? `
<script>
// Attendre que le DOM soit chargé pour les scripts
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function() {
// Le script embed.js se chargera automatiquement
});
}
</script>
` : ''}
</body>
</html>
`);
// Si c'est un iframe, le charger après que le document soit écrit
if (config.embedType === 'iframe') {
previewWindow.document.close();
}
}
}
}

View file

@ -5,6 +5,9 @@
@if (isLoading) {
<span class="loading-indicator"></span>
}
<a routerLink="/embed" class="btn-share" title="Partager et intégrer les événements">
🔗 Partager
</a>
</div>
@if(showOptions){

View file

@ -63,9 +63,30 @@
.toolbar {
display: flex;
align-items: center;
gap: 12px;
justify-content: space-between;
padding: 8px 12px;
.btn-share {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
text-decoration: none;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 500;
transition: all 0.2s ease;
white-space: nowrap;
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
}
.loading {
color: #007bff;
font-size: 12px;

View file

@ -1,5 +1,5 @@
import {Component, inject, signal, OnDestroy, OnInit} from '@angular/core';
import {Router} from '@angular/router';
import {Router, RouterLink} from '@angular/router';
import {FormsModule} from '@angular/forms';
import {Menu} from './menu/menu';
import {AllEvents} from '../../maps/all-events/all-events';
@ -25,7 +25,8 @@ import {NgClass} from '@angular/common';
Osm,
FormsModule,
WhatFilterComponent,
NgClass
NgClass,
RouterLink
],
templateUrl: './home.html',
styleUrl: './home.scss'

View file

@ -1,38 +1,45 @@
<menu>
<div class="menu-overlay" [class.overlay-active]="isMenuOpen" (click)="closeMenu()"></div>
<div class="menu-header">
<h1>OpenEventDatabase</h1>
<button class="burger-menu-toggle" (click)="toggleMenu()" [attr.aria-expanded]="isMenuOpen" aria-label="Toggle menu">
<span class="burger-line"></span>
<span class="burger-line"></span>
<span class="burger-line"></span>
</button>
</div>
<nav class="nav">
<nav class="nav" [class.nav-open]="isMenuOpen">
<div class="nav-section quick-tools">
<h3>Outils rapides</h3>
<a routerLink="/embed" class="link highlight">🔗 Intégration embarquée</a>
<a routerLink="/batch-edit" class="link highlight">⚡ Modification en masse</a>
<a routerLink="/batch-edit" class="link highlight" (click)="closeMenu()">⚡ Modification en masse</a>
</div>
<div class="nav-section">
<h3>Navigation principale</h3>
<a routerLink="/" class="link">🏠 Accueil</a>
<a routerLink="/agenda" class="link">📅 Agenda</a>
<a routerLink="/research" class="link">🔍 Recherche</a>
<a routerLink="/" class="link" (click)="closeMenu()">🏠 Accueil</a>
<a routerLink="/agenda" class="link" (click)="closeMenu()">📅 Agenda</a>
<a routerLink="/research" class="link" (click)="closeMenu()">🔍 Recherche</a>
<a routerLink="/embed" class="link" (click)="closeMenu()">🔗 Intégration embarquée</a>
</div>
<div class="nav-section">
<h3>Outils d'administration</h3>
<a routerLink="/unlocated-events" class="link">📍 Événements non localisés</a>
<a routerLink="/nouvelles-categories" class="link">🏷️ Nouvelles catégories</a>
<a routerLink="/unlocated-events" class="link" (click)="closeMenu()">📍 Événements non localisés</a>
<a routerLink="/nouvelles-categories" class="link" (click)="closeMenu()">🏷️ Nouvelles catégories</a>
</div>
<div class="nav-section">
<h3>Intégration & API</h3>
<a routerLink="/events-docs" class="link">📚 Documentation API</a>
<a href="/stats" class="link">📊 Statistiques</a>
<a routerLink="/events-docs" class="link" (click)="closeMenu()">📚 Documentation API</a>
<a href="/stats" class="link" (click)="closeMenu()">📊 Statistiques</a>
</div>
<div class="nav-section">
<h3>Communauté</h3>
<a routerLink="/community-upcoming" class="link">👥 Community à venir</a>
<a href="https://source.cipherbliss.com/tykayn/oedb-backend" class="link" target="_blank">💻 Sources</a>
<a routerLink="/community-upcoming" class="link" (click)="closeMenu()">👥 Community à venir</a>
<a href="https://source.cipherbliss.com/tykayn/oedb-backend" class="link" target="_blank" (click)="closeMenu()">💻 Sources</a>
</div>
</nav>

View file

@ -4,18 +4,111 @@
border-right: 1px solid #e9ecef;
min-height: 100vh;
padding: 1rem;
@media (max-width: 768px) {
position: relative;
min-height: auto;
padding: 0.75rem;
border-right: none;
border-bottom: 1px solid #e9ecef;
}
}
// Overlay pour mobile (masqué par défaut, montré quand le menu est ouvert)
.menu-overlay {
display: none;
@media (max-width: 768px) {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
&.overlay-active {
display: block;
opacity: 1;
pointer-events: all;
}
}
}
.menu-header {
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 2px solid #3498db;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
h1 {
color: #2c3e50;
margin: 0;
font-size: 1.5rem;
font-weight: 700;
flex: 1;
@media (max-width: 768px) {
font-size: 1.2rem;
}
}
@media (max-width: 768px) {
margin-bottom: 1rem;
padding-bottom: 0.75rem;
}
}
// Bouton burger menu
.burger-menu-toggle {
display: none;
flex-direction: column;
justify-content: space-around;
width: 28px;
height: 28px;
background: transparent;
border: none;
cursor: pointer;
padding: 0;
z-index: 1001;
transition: all 0.3s ease;
@media (max-width: 768px) {
display: flex;
}
&:focus {
outline: 2px solid #3498db;
outline-offset: 2px;
}
}
.burger-line {
width: 100%;
height: 3px;
background: #2c3e50;
border-radius: 2px;
transition: all 0.3s ease;
transform-origin: center;
}
.burger-menu-toggle[aria-expanded="true"] {
.burger-line:nth-child(1) {
transform: rotate(45deg) translate(7px, 7px);
}
.burger-line:nth-child(2) {
opacity: 0;
}
.burger-line:nth-child(3) {
transform: rotate(-45deg) translate(7px, -7px);
}
}
@ -23,6 +116,28 @@
display: flex;
flex-direction: column;
gap: 2rem;
transition: all 0.3s ease;
@media (max-width: 768px) {
position: relative;
z-index: 1000;
background: #f8f9fa;
max-height: 0;
overflow: hidden;
opacity: 0;
gap: 1.5rem;
border-radius: 0 0 8px 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
&.nav-open {
max-height: 2000px;
opacity: 1;
margin-top: 1rem;
padding: 1rem;
padding-top: 1rem;
border-top: 1px solid #e9ecef;
}
}
}
.nav-section {

View file

@ -18,6 +18,8 @@ export class Menu {
public onDownloadCSV?: () => void;
protected whats: any = [];
isMenuOpen = false;
constructor() {
let keys = Object.keys(oedb_what_categories.presets.what);
@ -36,4 +38,12 @@ export class Menu {
toggleView() { this.onToggleView && this.onToggleView(); }
downloadGeoJSON() { this.onDownloadGeoJSON && this.onDownloadGeoJSON(); }
downloadCSV() { this.onDownloadCSV && this.onDownloadCSV(); }
toggleMenu() {
this.isMenuOpen = !this.isMenuOpen;
}
closeMenu() {
this.isMenuOpen = false;
}
}