Files
Mario 2b0ce62d33
Some checks failed
Host-Based Deploy (Java 21 Fix) / build-and-run (push) Has been cancelled
Menp überarbeitet
2026-04-08 16:52:43 +02:00

583 lines
23 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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/nav.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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
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>