This commit is contained in:
2026-07-22 21:15:44 +02:00
parent 1176b98522
commit 751b6996c4
8 changed files with 375 additions and 19 deletions

View File

@@ -2,6 +2,7 @@ document.addEventListener('DOMContentLoaded', () => {
const body = document.body;
const isLoggedIn = body.dataset.auth === '1';
const childGender = body.dataset.childGender || '';
const locationPreference = body.dataset.locationPreference || 'prompt';
const header = document.querySelector('.site-header');
const headerSentinel = document.getElementById('headerSentinel');
@@ -52,6 +53,8 @@ document.addEventListener('DOMContentLoaded', () => {
// Events from backend (injected on page); fallback to empty
const events = Array.isArray(window.__events) ? window.__events : [];
const threads = Array.isArray(window.__threads) ? window.__threads : [];
const EVENTS_RADIUS_KM = 20;
const LOCATION_STORAGE_KEY = 'pkt_user_location';
const el = {
sliderTrack: document.getElementById('eventSlider'),
@@ -71,6 +74,7 @@ document.addEventListener('DOMContentLoaded', () => {
quickLat: document.getElementById('qsLat'),
quickLng: document.getElementById('qsLng'),
quickGeo: document.getElementById('quickGeo'),
eventsSection: document.getElementById('events'),
};
const fmtDate = (iso) => {
@@ -78,36 +82,182 @@ document.addEventListener('DOMContentLoaded', () => {
return d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' });
};
const now = () => new Date();
const setCookie = (name, value, days = 30) => {
const expires = new Date(Date.now() + (days * 86400000)).toUTCString();
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/; SameSite=Lax`;
};
const parseCookie = (name) => {
const prefix = `${name}=`;
return document.cookie
.split(';')
.map(item => item.trim())
.find(item => item.startsWith(prefix))
?.slice(prefix.length) || '';
};
const persistLocation = (location) => {
if (!location || !Number.isFinite(location.lat) || !Number.isFinite(location.lng)) return;
const payload = {
lat: Number(location.lat.toFixed(6)),
lng: Number(location.lng.toFixed(6)),
label: location.label || 'Mein Standort',
savedAt: new Date().toISOString(),
};
try {
window.localStorage.setItem(LOCATION_STORAGE_KEY, JSON.stringify(payload));
} catch (err) {
// ignore
}
setCookie('pkt_user_location', JSON.stringify(payload), 30);
if (el.quickLat) el.quickLat.value = payload.lat.toFixed(6);
if (el.quickLng) el.quickLng.value = payload.lng.toFixed(6);
if (el.quickLoc && (!el.quickLoc.value || el.quickLoc.value === 'Mein Standort')) {
el.quickLoc.value = payload.label;
}
};
const clearStoredLocation = () => {
try {
window.localStorage.removeItem(LOCATION_STORAGE_KEY);
} catch (err) {
// ignore
}
document.cookie = 'pkt_user_location=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=Lax';
if (el.quickLat) el.quickLat.value = '';
if (el.quickLng) el.quickLng.value = '';
if (el.quickLoc) el.quickLoc.value = '';
};
const getStoredLocation = () => {
const candidates = [];
try {
const local = window.localStorage.getItem(LOCATION_STORAGE_KEY);
if (local) candidates.push(local);
} catch (err) {
// ignore
}
const cookieValue = parseCookie('pkt_user_location');
if (cookieValue) candidates.push(decodeURIComponent(cookieValue));
for (const entry of candidates) {
try {
const parsed = JSON.parse(entry);
if (parsed && Number.isFinite(parsed.lat) && Number.isFinite(parsed.lng)) {
return {
lat: Number(parsed.lat),
lng: Number(parsed.lng),
label: parsed.label || 'Mein Standort',
};
}
} catch (err) {
// ignore invalid payloads
}
}
return null;
};
const haversineKm = (lat1, lng1, lat2, lng2) => {
const toRad = (value) => value * Math.PI / 180;
const earthRadiusKm = 6371;
const dLat = toRad(lat2 - lat1);
const dLng = toRad(lng2 - lng1);
const a = Math.sin(dLat / 2) ** 2
+ Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
return earthRadiusKm * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
};
const smallTag = (label) => `<span class="badge">${label}</span>`;
const getRankedEvents = (sourceEvents, location) => {
const normalized = sourceEvents.map(item => {
const startsAtDate = new Date(item.startsAt);
const isFuture = startsAtDate >= now();
const hasGeo = Number.isFinite(item.lat) && Number.isFinite(item.lng);
const distanceKm = (location && hasGeo)
? haversineKm(location.lat, location.lng, item.lat, item.lng)
: null;
return {
...item,
startsAtDate,
isFuture,
distanceKm,
};
});
if (!location) {
return normalized
.filter(item => item.isFuture)
.sort((a, b) => a.startsAtDate - b.startsAtDate)
.slice(0, 10);
}
const nearby = normalized.filter(item => item.distanceKm !== null && item.distanceKm <= EVENTS_RADIUS_KM);
const byDistanceThenDate = (a, b) => {
if (a.distanceKm !== b.distanceKm) return a.distanceKm - b.distanceKm;
return a.startsAtDate - b.startsAtDate;
};
const upcomingNearby = nearby
.filter(item => item.isFuture)
.sort(byDistanceThenDate);
if (upcomingNearby.length) {
return upcomingNearby.slice(0, 10);
}
const missedNearby = nearby
.filter(item => !item.isFuture)
.sort((a, b) => {
if (a.distanceKm !== b.distanceKm) return a.distanceKm - b.distanceKm;
return b.startsAtDate - a.startsAtDate;
})
.map(item => ({ ...item, missed: true }));
if (missedNearby.length) {
return missedNearby.slice(0, 10);
}
return normalized
.filter(item => item.isFuture)
.sort((a, b) => a.startsAtDate - b.startsAtDate)
.slice(0, 10);
};
const renderCard = (item) => {
const guest = !isLoggedIn;
const kids = item.allowKids ? 'Mit Kindern' : 'Ohne Kinder';
const distanceTag = Number.isFinite(item.distanceKm) ? smallTag(`${item.distanceKm.toFixed(1)} km`) : '';
const missedTag = item.missed ? smallTag('Das hast du verpasst') : '';
return `
<article class="card event-card-small">
<div class="muted small">${fmtDate(item.startsAt)}</div>
<h3>${item.title}</h3>
<p class="muted">${item.teaser}</p>
<div class="muted small">📍 ${item.city || item.region || ''}</div>
<div class="event__tags">${smallTag(kids)} ${item.visibility === 'public' ? smallTag('Öffentlich') : smallTag('Mitglieder')}</div>
<div class="event__tags">${smallTag(kids)} ${item.visibility === 'public' ? smallTag('Öffentlich') : smallTag('Mitglieder')} ${distanceTag} ${missedTag}</div>
<div class="flex gap-8" style="margin-top:8px;">
<button class="btn ghost" data-event-detail="${item.id}">Details</button>
${guest ? '' : '<button class="btn">Teilnehmen</button>'}
${guest || item.missed ? '' : '<button class="btn">Teilnehmen</button>'}
</div>
</article>`;
};
const renderSlider = () => {
const renderSlider = (location = null) => {
if (!el.sliderTrack) return;
if (!events.length) {
const rankedEvents = getRankedEvents(events, location);
if (!rankedEvents.length) {
el.sliderTrack.innerHTML = '<p class="muted">Keine Events vorhanden.</p>';
return;
}
el.sliderTrack.innerHTML = events.slice(0, 10).map(renderCard).join('');
el.sliderTrack.innerHTML = rankedEvents.map(renderCard).join('');
el.sliderTrack.querySelectorAll('[data-event-detail]').forEach(btn => {
btn.addEventListener('click', () => {
const id = parseInt(btn.getAttribute('data-event-detail'), 10);
const ev = events.find(e => e.id === id);
const ev = rankedEvents.find(e => e.id === id);
if (!ev || !el.modal || !el.modalBody || !el.modalTitle) return;
el.modalTitle.textContent = ev.title;
el.modalBody.innerHTML = `
@@ -115,6 +265,8 @@ document.addEventListener('DOMContentLoaded', () => {
<p class="muted">${ev.teaser}</p>
<p>${ev.description}</p>
${ev.locationLabel ? `<p><strong>Ort:</strong> ${ev.locationLabel}</p>` : ''}
${Number.isFinite(ev.distanceKm) ? `<p><strong>Entfernung:</strong> ${ev.distanceKm.toFixed(1)} km</p>` : ''}
${ev.missed ? '<p><strong>Hinweis:</strong> Das hast du verpasst.</p>' : ''}
<p><strong>Kinder:</strong> ${ev.allowKids ? 'Mit Kindern' : 'Ohne Kinder'}</p>
<p><strong>Sichtbarkeit:</strong> ${ev.visibility === 'public' ? 'Öffentlich' : 'Nur Mitglieder'}</p>
`;
@@ -160,6 +312,56 @@ document.addEventListener('DOMContentLoaded', () => {
el.threadSliderPrev?.addEventListener('click', () => scrollSlider(el.threadSliderViewport, 'prev'));
el.threadSliderNext?.addEventListener('click', () => scrollSlider(el.threadSliderViewport, 'next'));
const applyStoredLocationToForm = () => {
const stored = getStoredLocation();
if (!stored) return null;
if (el.quickLat) el.quickLat.value = stored.lat.toFixed(6);
if (el.quickLng) el.quickLng.value = stored.lng.toFixed(6);
if (el.quickLoc && !el.quickLoc.value) el.quickLoc.value = stored.label || 'Mein Standort';
return stored;
};
const requestLocation = (options = {}) => new Promise((resolve) => {
if (!navigator.geolocation) {
resolve(null);
return;
}
navigator.geolocation.getCurrentPosition(
(pos) => {
const location = {
lat: pos.coords.latitude,
lng: pos.coords.longitude,
label: 'Mein Standort',
};
persistLocation(location);
resolve(location);
},
() => resolve(null),
{
enableHighAccuracy: true,
timeout: options.timeout || 10000,
maximumAge: options.maximumAge || 300000,
}
);
});
const ensureEventsLocation = async () => {
if (locationPreference === 'disabled') {
clearStoredLocation();
renderSlider(null);
return null;
}
const stored = applyStoredLocationToForm();
if (stored && locationPreference !== 'enabled') {
renderSlider(stored);
return stored;
}
const location = await requestLocation();
renderSlider(location || stored);
return location;
};
// Quick search with geocoding
const geocode = async (query) => {
const url = 'https://nominatim.openstreetmap.org/search?format=jsonv2&limit=1&q=' + encodeURIComponent(query);
@@ -178,11 +380,12 @@ document.addEventListener('DOMContentLoaded', () => {
}
navigator.geolocation.getCurrentPosition(
(pos) => {
if (el.quickLat && el.quickLng) {
el.quickLat.value = pos.coords.latitude.toFixed(6);
el.quickLng.value = pos.coords.longitude.toFixed(6);
if (el.quickLoc) el.quickLoc.value = 'Mein Standort';
}
persistLocation({
lat: pos.coords.latitude,
lng: pos.coords.longitude,
label: 'Mein Standort',
});
renderSlider(getStoredLocation());
},
() => alert('Standort konnte nicht ermittelt werden.')
);
@@ -215,6 +418,30 @@ document.addEventListener('DOMContentLoaded', () => {
});
el.modal?.addEventListener('click', (e) => { if (e.target === el.modal) el.modal.classList.remove('open'); });
renderSlider();
document.querySelectorAll('[data-nav-events]').forEach(link => {
link.addEventListener('click', async () => {
if (window.location.pathname === '/' && window.location.hash === '#events') {
await ensureEventsLocation();
}
});
});
const initialLocation = locationPreference === 'disabled' ? null : applyStoredLocationToForm();
if (locationPreference === 'disabled') {
clearStoredLocation();
}
renderSlider(initialLocation);
renderThreadSlider();
if (locationPreference === 'enabled' && window.location.pathname === '/') {
ensureEventsLocation();
} else if (window.location.hash === '#events' || window.location.hash === '#quicksearch') {
ensureEventsLocation();
}
window.addEventListener('hashchange', () => {
if (window.location.hash === '#events' || window.location.hash === '#quicksearch') {
ensureEventsLocation();
}
});
});