Some checks failed
Host-Based Deploy (Java 21 Fix) / build-and-run (push) Has been cancelled
583 lines
23 KiB
HTML
583 lines
23 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<link rel="icon" href="/img/icon.png" type="image/png">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Veranstaltungen – xXx Sphere</title>
|
||
<link rel="stylesheet" href="/css/variables.css">
|
||
<link rel="stylesheet" href="/css/style.css">
|
||
<style>
|
||
/* ── Tab-Bar ── */
|
||
.events-tabs {
|
||
display: flex;
|
||
gap: 0;
|
||
align-items: center;
|
||
border-bottom: 1px solid var(--color-secondary);
|
||
margin-bottom: 1.25rem;
|
||
}
|
||
.events-tab-btn {
|
||
background: none;
|
||
border: none;
|
||
border-bottom: 3px solid transparent;
|
||
border-radius: 0;
|
||
padding: 0.6rem 1.25rem;
|
||
font-size: 0.95rem;
|
||
font-weight: 600;
|
||
color: var(--color-primary);
|
||
border-bottom-color: var(--color-primary);
|
||
cursor: default;
|
||
margin-bottom: -1px;
|
||
width: auto;
|
||
margin-top: 0;
|
||
}
|
||
.filter-badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: var(--color-primary);
|
||
color: #fff;
|
||
border-radius: 50%;
|
||
width: 1rem;
|
||
height: 1rem;
|
||
font-size: 0.62rem;
|
||
font-weight: 700;
|
||
line-height: 1;
|
||
}
|
||
|
||
/* ── Filter-Drawer ── */
|
||
.filter-overlay-bg {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0,0,0,0.55);
|
||
z-index: 200;
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
transition: opacity 0.2s;
|
||
}
|
||
.filter-overlay-bg.open { opacity: 1; pointer-events: all; }
|
||
|
||
.filter-drawer {
|
||
position: fixed;
|
||
top: 0; right: 0; bottom: 0;
|
||
width: min(320px, 92vw);
|
||
background: var(--color-card);
|
||
border-left: 1px solid var(--color-secondary);
|
||
z-index: 201;
|
||
display: flex;
|
||
flex-direction: column;
|
||
transform: translateX(100%);
|
||
transition: transform 0.25s ease;
|
||
overflow: hidden;
|
||
}
|
||
.filter-drawer.open { transform: translateX(0); }
|
||
|
||
.filter-drawer-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 1rem 1.25rem 0.75rem;
|
||
border-bottom: 1px solid var(--color-secondary);
|
||
flex-shrink: 0;
|
||
}
|
||
.filter-drawer-header h2 {
|
||
font-size: 1rem;
|
||
font-weight: 700;
|
||
color: var(--color-primary);
|
||
margin: 0;
|
||
}
|
||
.filter-close-btn {
|
||
background: none;
|
||
border: none;
|
||
color: var(--color-muted);
|
||
font-size: 1.3rem;
|
||
cursor: pointer;
|
||
padding: 0.2rem 0.4rem;
|
||
line-height: 1;
|
||
border-radius: 4px;
|
||
}
|
||
.filter-close-btn:hover { background: var(--color-secondary); color: var(--color-text); }
|
||
|
||
.filter-drawer-body {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 1rem 1.25rem;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 1.25rem;
|
||
}
|
||
.filter-drawer-footer {
|
||
padding: 0.75rem 1.25rem;
|
||
border-top: 1px solid var(--color-secondary);
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
flex-shrink: 0;
|
||
}
|
||
.apply-btn { flex: 1; padding: 0.65rem; font-size: 0.9rem; }
|
||
.reset-btn {
|
||
padding: 0.65rem 1rem;
|
||
font-size: 0.9rem;
|
||
background: var(--color-secondary);
|
||
color: var(--color-muted);
|
||
border: 1px solid var(--color-secondary);
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
}
|
||
.reset-btn:hover { color: var(--color-text); }
|
||
|
||
/* ── Filter-Elemente ── */
|
||
.filter-group { display: flex; flex-direction: column; gap: 0.35rem; }
|
||
.filter-group > label {
|
||
font-size: 0.78rem;
|
||
color: var(--color-muted);
|
||
margin: 0;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
}
|
||
.range-val { color: var(--color-text); }
|
||
.filter-group input[type="range"] {
|
||
padding: 0; background: none; border: none;
|
||
accent-color: var(--color-primary); width: 100%;
|
||
}
|
||
|
||
.suggest-list {
|
||
position: absolute; top: 100%; left: 0; right: 0;
|
||
background: var(--color-card); border: 1px solid var(--color-secondary);
|
||
border-top: none; border-radius: 0 0 6px 6px;
|
||
z-index: 210; display: none; list-style: none;
|
||
margin: 0; padding: 0; max-height: 200px; overflow-y: auto;
|
||
}
|
||
|
||
.filter-hint {
|
||
font-size: 0.78rem;
|
||
color: var(--color-muted);
|
||
background: rgba(var(--color-primary-rgb,180,0,60),.07);
|
||
border: 1px solid var(--color-secondary);
|
||
border-radius: 6px;
|
||
padding: 0.5rem 0.75rem;
|
||
line-height: 1.45;
|
||
}
|
||
|
||
/* ── Event-Liste ── */
|
||
.result-count { font-size: 0.85rem; color: var(--color-muted); margin-bottom: 0.75rem; }
|
||
|
||
.event-list { display: flex; flex-direction: column; gap: 0.75rem; }
|
||
.event-card {
|
||
background: var(--color-card);
|
||
border: 1px solid var(--color-secondary);
|
||
border-radius: 10px;
|
||
display: flex; gap: 0.75rem; padding: 0.75rem;
|
||
text-decoration: none; color: inherit;
|
||
transition: border-color 0.15s;
|
||
}
|
||
.event-card:hover { border-color: var(--color-primary); }
|
||
.event-card-img {
|
||
width: 72px; height: 72px; border-radius: 8px;
|
||
background: var(--color-secondary); flex-shrink: 0;
|
||
overflow: hidden; display: flex; align-items: center;
|
||
justify-content: center; font-size: 1.6rem;
|
||
}
|
||
.event-card-img img { width: 100%; height: 100%; object-fit: cover; }
|
||
.event-card-body { flex: 1; min-width: 0; }
|
||
.event-card-title { font-weight: 600; font-size: 0.95rem; margin: 0 0 0.2rem; }
|
||
.event-card-sub { font-size: 0.78rem; color: var(--color-muted); margin-bottom: 0.1rem; }
|
||
.event-card-dist { font-size: 0.78rem; color: var(--color-muted); }
|
||
.attend-tag {
|
||
display: inline-block; font-size: 0.72rem;
|
||
background: rgba(var(--color-primary-rgb,180,0,60),.12);
|
||
color: var(--color-primary); border-radius: 4px;
|
||
padding: 0.1rem 0.4rem; margin-left: 0.4rem;
|
||
}
|
||
.follow-tag {
|
||
display: inline-block; font-size: 0.72rem;
|
||
background: rgba(var(--color-primary-rgb,180,0,60),.08);
|
||
color: var(--color-muted); border-radius: 4px;
|
||
padding: 0.1rem 0.4rem; margin-left: 0.4rem;
|
||
}
|
||
|
||
.event-card-skeleton {
|
||
height: 86px; background: var(--color-secondary);
|
||
border-radius: 10px; animation: pulse 1.4s infinite;
|
||
}
|
||
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.5} }
|
||
|
||
.empty-state {
|
||
text-align: center; padding: 3rem 1rem;
|
||
color: var(--color-muted);
|
||
}
|
||
.empty-state .icon { font-size: 2.5rem; margin-bottom: 0.75rem; }
|
||
|
||
.sentinel { height: 1px; }
|
||
</style>
|
||
</head>
|
||
<body class="app">
|
||
|
||
<!-- ── Autocomplete (außerhalb des transformierten Drawers) ── -->
|
||
<ul id="citySuggestions" style="position:fixed;display:none;background:var(--color-card);border:1px solid var(--color-secondary);border-radius:6px;z-index:400;list-style:none;margin:0;padding:0;max-height:200px;overflow-y:auto;box-shadow:0 4px 12px rgba(0,0,0,.15);"></ul>
|
||
|
||
<!-- ── Filter-Overlay ── -->
|
||
<div class="filter-overlay-bg" id="filterBg"></div>
|
||
<div class="filter-drawer" id="filterDrawer">
|
||
<div class="filter-drawer-header">
|
||
<h2>Filter</h2>
|
||
<button class="filter-close-btn" id="filterCloseBtn" aria-label="Filter schließen">✕</button>
|
||
</div>
|
||
<div class="filter-drawer-body">
|
||
<div class="filter-hint">
|
||
Veranstaltungen von abonnierten Locations werden immer angezeigt, unabhängig vom Umkreis.
|
||
</div>
|
||
|
||
<div class="filter-group">
|
||
<label>Ort</label>
|
||
<div style="position:relative;" id="cityRow">
|
||
<input type="text" id="filterCity" placeholder="Stadt suchen und auswählen…" autocomplete="off"
|
||
style="padding-right:2rem;" oninput="onCityInput()">
|
||
<button id="filterCityClear" onclick="clearCity()" title="Auswahl aufheben"
|
||
style="display:none;position:absolute;right:0.4rem;top:50%;transform:translateY(-50%);background:none;border:none;color:var(--color-muted);font-size:1.1rem;cursor:pointer;padding:0.1rem 0.3rem;margin:0;width:auto;line-height:1;">×</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="filter-group">
|
||
<label>
|
||
Umkreis
|
||
<span class="range-val" id="distVal">50 km</span>
|
||
</label>
|
||
<input type="range" id="filterRadius" min="5" max="250" step="5" value="50"
|
||
oninput="document.getElementById('distVal').textContent = this.value + ' km'">
|
||
</div>
|
||
</div>
|
||
<div class="filter-drawer-footer">
|
||
<button class="btn apply-btn" id="applyBtn">Anwenden</button>
|
||
<button class="reset-btn" id="resetBtn">Zurücksetzen</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="main">
|
||
<div class="content">
|
||
<div class="events-tabs">
|
||
<button class="events-tab-btn">Veranstaltungen</button>
|
||
<div style="margin-left:auto;display:flex;align-items:center;gap:0.4rem;padding-bottom:1px;">
|
||
<button id="filterOpenBtn" title="Filter"
|
||
style="display:flex;align-items:center;justify-content:center;position:relative;width:2rem;height:2rem;border-radius:50%;background:var(--color-secondary);color:var(--color-muted);border:none;cursor:pointer;transition:background 0.15s,color 0.15s;padding:0;"
|
||
onmouseover="this.style.background='var(--color-primary)';this.style.color='#fff';"
|
||
onmouseout="this.style.background='var(--color-secondary)';this.style.color='var(--color-muted)';">
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24"
|
||
fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
||
<line x1="4" y1="6" x2="20" y2="6"/><line x1="8" y1="12" x2="16" y2="12"/><line x1="11" y1="18" x2="13" y2="18"/>
|
||
</svg>
|
||
<span class="filter-badge" id="filterBadge" style="display:none;position:absolute;top:-3px;right:-3px;"></span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="resultCount" class="result-count" style="display:none;"></div>
|
||
<div class="event-list" id="eventList"></div>
|
||
<p class="empty-state" id="emptyState" style="display:none;">
|
||
<span class="icon">🗓</span><br>
|
||
Keine Veranstaltungen gefunden.<br>
|
||
<span style="font-size:0.85rem;">Passe den Filter an oder abonniere Locations.</span>
|
||
</p>
|
||
<div class="sentinel" id="sentinel"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<script src="/js/icons.js"></script>
|
||
<script src="/js/sidebar.js"></script>
|
||
<script>
|
||
// ── State ─────────────────────────────────────────────────────────────────────
|
||
const BATCH = 20;
|
||
let state = { allIds: [], loaded: 0, loading: false, lat: null, lon: null };
|
||
|
||
// Gespeicherter Filter aus Datenbank
|
||
let savedCity = null;
|
||
let savedLat = null;
|
||
let savedLon = null;
|
||
let savedRadius = 50;
|
||
|
||
// Aktuell angewendeter Filter (für Batch-Requests)
|
||
let activeLat = null;
|
||
let activeLon = null;
|
||
let activeRadius = 50;
|
||
|
||
// Temporärer Eingabe-Zustand im Drawer
|
||
let _inputLat = null;
|
||
let _inputLon = null;
|
||
let _cityTimer = null;
|
||
|
||
// Abonnierte Location-IDs (für follow-Tag in der Liste)
|
||
let followedLocationIds = new Set();
|
||
|
||
// ── Auth & Init ───────────────────────────────────────────────────────────────
|
||
fetch('/login/me').then(r => {
|
||
if (r.status === 401) { window.location.href = '/login.html'; return null; }
|
||
return r.ok ? r.json() : null;
|
||
}).then(async user => {
|
||
if (!user) return;
|
||
|
||
// Gespeicherten Filter laden
|
||
if (user.filterCity) {
|
||
savedCity = user.filterCity;
|
||
savedLat = user.filterLat;
|
||
savedLon = user.filterLon;
|
||
savedRadius = user.filterMaxDistKm || 50;
|
||
|
||
document.getElementById('filterCity').value = savedCity;
|
||
document.getElementById('filterCity').readOnly = true;
|
||
document.getElementById('filterCityClear').style.display = '';
|
||
document.getElementById('filterRadius').value = savedRadius;
|
||
document.getElementById('distVal').textContent = savedRadius + ' km';
|
||
_inputLat = savedLat;
|
||
_inputLon = savedLon;
|
||
}
|
||
|
||
// Abonnierte Locations laden
|
||
try {
|
||
const followRes = await fetch('/locations/followed');
|
||
if (followRes.ok) {
|
||
const followData = await followRes.json();
|
||
followedLocationIds = new Set(followData.ids || []);
|
||
}
|
||
} catch (_) {}
|
||
|
||
updateFilterBadge();
|
||
loadIds();
|
||
}).catch(() => {});
|
||
|
||
// ── Filter-Drawer öffnen/schließen ───────────────────────────────────────────
|
||
const filterDrawer = document.getElementById('filterDrawer');
|
||
const filterBg = document.getElementById('filterBg');
|
||
|
||
function openFilter() {
|
||
filterDrawer.classList.add('open');
|
||
filterBg.classList.add('open');
|
||
document.body.style.overflow = 'hidden';
|
||
}
|
||
function closeFilter() {
|
||
filterDrawer.classList.remove('open');
|
||
filterBg.classList.remove('open');
|
||
document.body.style.overflow = '';
|
||
}
|
||
|
||
document.getElementById('filterOpenBtn').addEventListener('click', openFilter);
|
||
document.getElementById('filterCloseBtn').addEventListener('click', closeFilter);
|
||
filterBg.addEventListener('click', closeFilter);
|
||
|
||
document.addEventListener('keydown', e => {
|
||
if (e.key === 'Escape' && filterDrawer.classList.contains('open')) closeFilter();
|
||
});
|
||
|
||
// ── Ort-Autocomplete ─────────────────────────────────────────────────────────
|
||
function onCityInput() {
|
||
const q = document.getElementById('filterCity').value.trim();
|
||
_inputLat = null; _inputLon = null;
|
||
document.getElementById('filterCityClear').style.display = 'none';
|
||
clearTimeout(_cityTimer);
|
||
if (q.length < 2) { document.getElementById('citySuggestions').style.display = 'none'; return; }
|
||
_cityTimer = setTimeout(() => fetchCitySuggestions(q), 300);
|
||
}
|
||
|
||
async function fetchCitySuggestions(q) {
|
||
try {
|
||
const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(q)}&format=json&addressdetails=1&limit=5&featuretype=city`;
|
||
const res = await fetch(url, { headers: { 'Accept-Language': 'de' } });
|
||
if (!res.ok) return;
|
||
const results = await res.json();
|
||
const ul = document.getElementById('citySuggestions');
|
||
if (!results.length) { ul.style.display = 'none'; return; }
|
||
ul.innerHTML = results.map(r => {
|
||
const city = r.address.city || r.address.town || r.address.village || r.address.county || r.name;
|
||
const country = r.address.country || '';
|
||
const label = city + (country ? ', ' + country : '');
|
||
return `<li style="padding:0.5rem 0.75rem;cursor:pointer;font-size:0.9rem;border-bottom:1px solid var(--color-secondary);"
|
||
onmousedown="selectCity(event,'${label.replace(/'/g,"\\'")}',${parseFloat(r.lat)},${parseFloat(r.lon)})">${label}</li>`;
|
||
}).join('');
|
||
const rect = document.getElementById('filterCity').getBoundingClientRect();
|
||
ul.style.top = (rect.bottom + 2) + 'px';
|
||
ul.style.left = rect.left + 'px';
|
||
ul.style.width = rect.width + 'px';
|
||
ul.style.display = '';
|
||
} catch (_) {}
|
||
}
|
||
|
||
function selectCity(e, label, lat, lon) {
|
||
e.preventDefault();
|
||
const inp = document.getElementById('filterCity');
|
||
inp.value = label; inp.readOnly = true;
|
||
_inputLat = lat; _inputLon = lon;
|
||
document.getElementById('filterCityClear').style.display = '';
|
||
document.getElementById('citySuggestions').style.display = 'none';
|
||
}
|
||
|
||
function clearCity() {
|
||
const inp = document.getElementById('filterCity');
|
||
inp.value = ''; inp.readOnly = false;
|
||
_inputLat = null; _inputLon = null;
|
||
document.getElementById('filterCityClear').style.display = 'none';
|
||
inp.focus();
|
||
}
|
||
|
||
document.addEventListener('click', e => {
|
||
const ul = document.getElementById('citySuggestions');
|
||
if (!e.target.closest('#cityRow') && !ul.contains(e.target)) ul.style.display = 'none';
|
||
});
|
||
|
||
// ── Anwenden & Zurücksetzen ───────────────────────────────────────────────────
|
||
document.getElementById('applyBtn').addEventListener('click', () => {
|
||
closeFilter();
|
||
|
||
const city = document.getElementById('filterCity').value.trim();
|
||
const radius = parseInt(document.getElementById('filterRadius').value);
|
||
|
||
// Filter in DB speichern
|
||
saveFilterToDb(city || null, _inputLat, _inputLon, radius);
|
||
|
||
savedCity = city || null;
|
||
savedLat = _inputLat;
|
||
savedLon = _inputLon;
|
||
savedRadius = radius;
|
||
|
||
updateFilterBadge();
|
||
resetState();
|
||
loadIds();
|
||
});
|
||
|
||
document.getElementById('resetBtn').addEventListener('click', () => {
|
||
clearCity();
|
||
document.getElementById('filterRadius').value = 50;
|
||
document.getElementById('distVal').textContent = '50 km';
|
||
_inputLat = null; _inputLon = null;
|
||
});
|
||
|
||
function saveFilterToDb(city, lat, lon, radius) {
|
||
fetch('/user/me/location-filter', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ filterCity: city, filterLat: lat, filterLon: lon, filterMaxDistKm: radius })
|
||
}).catch(() => {});
|
||
}
|
||
|
||
function updateFilterBadge() {
|
||
const active = savedCity != null;
|
||
const badge = document.getElementById('filterBadge');
|
||
badge.textContent = active ? '1' : '';
|
||
badge.style.display = active ? 'inline-flex' : 'none';
|
||
}
|
||
|
||
// ── Laden ─────────────────────────────────────────────────────────────────────
|
||
function resetState() {
|
||
state.allIds = [];
|
||
state.loaded = 0;
|
||
state.loading = false;
|
||
activeLat = savedLat;
|
||
activeLon = savedLon;
|
||
activeRadius = savedRadius;
|
||
document.getElementById('eventList').innerHTML = '';
|
||
document.getElementById('emptyState').style.display = 'none';
|
||
document.getElementById('resultCount').style.display = 'none';
|
||
}
|
||
|
||
async function loadIds() {
|
||
resetState();
|
||
showSkeletons(4);
|
||
|
||
activeLat = savedLat;
|
||
activeLon = savedLon;
|
||
activeRadius = savedRadius;
|
||
|
||
let url = '/location-events/ids?maxDistanceKm=' + activeRadius;
|
||
if (activeLat != null && activeLon != null) {
|
||
url += '&lat=' + activeLat + '&lon=' + activeLon;
|
||
}
|
||
|
||
try {
|
||
const res = await fetch(url);
|
||
if (!res.ok) throw new Error();
|
||
const data = await res.json();
|
||
state.allIds = data.ids || [];
|
||
|
||
document.getElementById('eventList').innerHTML = '';
|
||
|
||
if (state.allIds.length === 0) {
|
||
document.getElementById('emptyState').style.display = '';
|
||
return;
|
||
}
|
||
|
||
const rc = document.getElementById('resultCount');
|
||
rc.textContent = `${data.total} Veranstaltung${data.total !== 1 ? 'en' : ''} gefunden`;
|
||
rc.style.display = '';
|
||
|
||
loadNextBatch();
|
||
} catch (_) {
|
||
document.getElementById('eventList').innerHTML =
|
||
'<div class="empty-state"><div class="icon">⚠️</div><p>Fehler beim Laden.</p></div>';
|
||
}
|
||
}
|
||
|
||
async function loadNextBatch() {
|
||
if (state.loading || state.loaded >= state.allIds.length) return;
|
||
state.loading = true;
|
||
|
||
const slice = state.allIds.slice(state.loaded, state.loaded + BATCH);
|
||
const body = { ids: slice, lat: activeLat ?? 0, lon: activeLon ?? 0 };
|
||
|
||
try {
|
||
const res = await fetch('/location-events/batch', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body)
|
||
});
|
||
if (res.ok) {
|
||
const previews = await res.json();
|
||
const list = document.getElementById('eventList');
|
||
previews.forEach(e => {
|
||
const imgHtml = e.imageData
|
||
? `<img src="data:image/jpeg;base64,${e.imageData}" alt="${escHtml(e.title)}">`
|
||
: '🗓';
|
||
const attendTag = e.attendingMe ? `<span class="attend-tag">Ich bin dabei</span>` : '';
|
||
const followTag = followedLocationIds.has(e.locationId) ? `<span class="follow-tag">★ Abonniert</span>` : '';
|
||
const distText = e.distanzKm >= 0 ? `${e.distanzKm} km` : '';
|
||
const a = document.createElement('a');
|
||
a.className = 'event-card';
|
||
a.href = `/community/event-detail.html?id=${e.eventId}`;
|
||
a.innerHTML = `
|
||
<div class="event-card-img">${imgHtml}</div>
|
||
<div class="event-card-body">
|
||
<div class="event-card-title">${escHtml(e.title)}${attendTag}${followTag}</div>
|
||
<div class="event-card-sub">📍 ${escHtml(e.locationName)}</div>
|
||
<div class="event-card-sub">🗓 ${formatDate(e.startAt)}</div>
|
||
<div class="event-card-dist">${distText ? distText + ' entfernt · ' : ''}${e.attendeeCount} Teilnehmer*in(nen)</div>
|
||
</div>`;
|
||
list.appendChild(a);
|
||
});
|
||
state.loaded += slice.length;
|
||
}
|
||
} catch (_) {}
|
||
|
||
state.loading = false;
|
||
}
|
||
|
||
function showSkeletons(n) {
|
||
document.getElementById('eventList').innerHTML =
|
||
Array(n).fill('<div class="event-card-skeleton"></div>').join('');
|
||
}
|
||
|
||
// ── Hilfsfunktionen ───────────────────────────────────────────────────────────
|
||
function escHtml(s) {
|
||
return (s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
function formatDate(dt) {
|
||
if (!dt) return '';
|
||
const d = new Date(dt);
|
||
return d.toLocaleDateString('de-DE', { weekday:'short', day:'2-digit', month:'2-digit', year:'numeric' })
|
||
+ ', ' + d.toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' }) + ' Uhr';
|
||
}
|
||
|
||
// ── IntersectionObserver für Scroll-Paging ────────────────────────────────────
|
||
const observer = new IntersectionObserver(entries => {
|
||
if (entries[0].isIntersecting) loadNextBatch();
|
||
}, { rootMargin: '300px' });
|
||
observer.observe(document.getElementById('sentinel'));
|
||
</script>
|
||
</body>
|
||
</html>
|