An den Verantaltungen und Locations gearbeitet
Some checks failed
Host-Based Deploy (Java 21 Fix) / build-and-run (push) Has been cancelled

This commit is contained in:
2026-04-05 23:04:09 +02:00
parent b81ad25c9f
commit 0f9f109067
65 changed files with 5260 additions and 3 deletions

View File

@@ -0,0 +1,177 @@
<!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>Veranstaltung xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.back-link { display:inline-flex; align-items:center; gap:0.35rem; color:var(--color-muted); font-size:0.88rem; text-decoration:none; margin-bottom:1rem; }
.back-link:hover { color:var(--color-primary); }
.evt-header { display:flex; gap:1rem; align-items:flex-start; margin-bottom:1.25rem; flex-wrap:wrap; }
.evt-img { width:120px; height:120px; border-radius:12px; background:var(--color-secondary); object-fit:cover; flex-shrink:0; display:flex; align-items:center; justify-content:center; font-size:3rem; overflow:hidden; border:2px solid var(--color-secondary); }
.evt-img img { width:100%; height:100%; object-fit:cover; }
.evt-meta { flex:1; min-width:0; }
.evt-title { font-size:1.4rem; font-weight:700; margin:0 0 0.3rem; }
.evt-location { color:var(--color-muted); font-size:0.88rem; margin-bottom:0.2rem; }
.evt-date { font-size:0.88rem; color:var(--color-muted); margin-bottom:0.5rem; }
.evt-desc { font-size:0.93rem; line-height:1.55; white-space:pre-wrap; word-break:break-word; margin-top:0.5rem; }
.attend-btn { display:inline-flex; align-items:center; gap:0.4rem; margin-top:0.75rem; }
.section-title { font-size:1rem; font-weight:700; margin:1.5rem 0 0.75rem; }
.gender-group { margin-bottom:1.25rem; }
.gender-label { font-size:0.78rem; font-weight:700; text-transform:uppercase; letter-spacing:0.06em; color:var(--color-muted); margin-bottom:0.5rem; }
.attendee-list { display:flex; flex-wrap:wrap; gap:0.6rem; }
.attendee-chip { display:flex; align-items:center; gap:0.5rem; background:var(--color-card); border:1px solid var(--color-secondary); border-radius:20px; padding:0.3rem 0.6rem 0.3rem 0.3rem; text-decoration:none; color:inherit; transition:border-color 0.15s; font-size:0.85rem; }
.attendee-chip:hover { border-color:var(--color-primary); }
.attendee-avatar { width:28px; height:28px; border-radius:50%; background:var(--color-secondary); object-fit:cover; flex-shrink:0; overflow:hidden; display:flex; align-items:center; justify-content:center; font-size:0.8rem; }
.attendee-avatar img { width:100%; height:100%; object-fit:cover; }
.count-badge { background:var(--color-secondary); border-radius:12px; padding:0.15rem 0.6rem; font-size:0.78rem; color:var(--color-muted); margin-left:0.25rem; display:inline-block; }
</style>
</head>
<body>
<div class="main">
<a id="backLink" href="/community/events.html" class="back-link">← Veranstaltungen</a>
<div id="content">
<p style="color:var(--color-muted);">Wird geladen…</p>
</div>
</div>
<script src="/js/sidebar.js"></script>
<script src="/js/icons.js"></script>
<script>
const params = new URLSearchParams(location.search);
const eventId = params.get('id');
let myUserId = null;
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:'long', day:'2-digit', month:'long', year:'numeric' })
+ ', ' + d.toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' }) + ' Uhr';
}
const GENDER_LABELS = {
WEIBLICH: 'Frauen',
MAENNLICH: 'Männer',
DIVERS: 'Divers',
UNBEKANNT: 'Sonstiges'
};
async function loadPage() {
if (!eventId) { document.getElementById('content').innerHTML = '<p>Keine Event-ID angegeben.</p>'; return; }
const [meRes, evtRes] = await Promise.all([
fetch('/login/me'),
fetch(`/location-events/${eventId}`)
]);
if (!evtRes.ok) { document.getElementById('content').innerHTML = '<p>Veranstaltung nicht gefunden.</p>'; return; }
if (meRes.ok) { const me = await meRes.json(); myUserId = me.userId; }
const evt = await evtRes.json();
// Rücklink zur Location
const backLink = document.getElementById('backLink');
if (evt.locationId) {
backLink.href = `/community/location-detail.html?id=${evt.locationId}`;
backLink.textContent = `${escHtml(evt.locationName) || 'Location'}`;
}
document.title = `${evt.title} xXx Sphere`;
renderPage(evt);
}
function renderPage(evt) {
const imgHtml = evt.imageData
? `<img src="data:image/jpeg;base64,${evt.imageData}" alt="${escHtml(evt.title)}">`
: '🗓';
// Teilnehmende nach Geschlecht gruppieren
const byGender = {};
(evt.attendees || []).forEach(a => {
const g = a.geschlecht || 'UNBEKANNT';
if (!byGender[g]) byGender[g] = [];
byGender[g].push(a);
});
const genderOrder = ['WEIBLICH', 'MAENNLICH', 'DIVERS', 'UNBEKANNT'];
const attendeesHtml = genderOrder
.filter(g => byGender[g] && byGender[g].length > 0)
.map(g => {
const chips = byGender[g].map(a => {
const avatarHtml = a.profilePictureLq
? `<img src="data:image/jpeg;base64,${a.profilePictureLq}" alt="${escHtml(a.name)}">`
: a.name.charAt(0).toUpperCase();
return `<a class="attendee-chip" href="/community/benutzer.html?userId=${a.userId}">
<div class="attendee-avatar">${avatarHtml}</div>
${escHtml(a.name)}
</a>`;
}).join('');
return `<div class="gender-group">
<div class="gender-label">${GENDER_LABELS[g] || g} <span class="count-badge">${byGender[g].length}</span></div>
<div class="attendee-list">${chips}</div>
</div>`;
}).join('');
const totalAttendees = (evt.attendees || []).length;
const attending = evt.attendingMe;
document.getElementById('content').innerHTML = `
<div class="evt-header">
<div class="evt-img">${imgHtml}</div>
<div class="evt-meta">
<div class="evt-title">${escHtml(evt.title)}</div>
${evt.locationName ? `<div class="evt-location">📍 <a href="/community/location-detail.html?id=${evt.locationId}" style="color:inherit;text-decoration:none;">${escHtml(evt.locationName)}</a></div>` : ''}
<div class="evt-date">🗓 ${formatDate(evt.startAt)}</div>
${evt.description ? `<div class="evt-desc">${escHtml(evt.description)}</div>` : ''}
<div class="attend-btn">
<button class="btn" id="attendBtn"
style="${attending ? 'background:var(--color-secondary);color:var(--color-text);' : ''}"
onclick="toggleAttend()">
${attending ? '✓ Ich bin dabei' : '+ Ich bin dabei'}
</button>
<span style="color:var(--color-muted);font-size:0.85rem;" id="attendCount">${totalAttendees} Teilnehmer*in(nen)</span>
</div>
</div>
</div>
${totalAttendees > 0 ? `
<div class="section-title">Teilnehmende</div>
${attendeesHtml}
` : '<p style="color:var(--color-muted);font-size:0.9rem;margin-top:1rem;">Noch keine Teilnehmenden.</p>'}
`;
}
async function toggleAttend() {
const res = await fetch(`/location-events/${eventId}/attend`, { method: 'POST' });
if (!res.ok) { alert('Fehler beim Aktualisieren.'); return; }
const data = await res.json();
const btn = document.getElementById('attendBtn');
const countEl = document.getElementById('attendCount');
if (btn) {
btn.textContent = data.attending ? '✓ Ich bin dabei' : '+ Ich bin dabei';
btn.style.background = data.attending ? 'var(--color-secondary)' : '';
btn.style.color = data.attending ? 'var(--color-text)' : '';
}
if (countEl) countEl.textContent = `${data.attendeeCount} Teilnehmer*in(nen)`;
// Teilnehmendenliste neu laden
const evtRes = await fetch(`/location-events/${eventId}`);
if (evtRes.ok) { renderPage(await evtRes.json()); }
}
loadPage();
</script>
</body>
</html>

View File

@@ -0,0 +1,582 @@
<!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,'&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>

View File

@@ -0,0 +1,628 @@
<!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>Location xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.back-link { display:inline-flex; align-items:center; gap:0.35rem; color:var(--color-muted); font-size:0.88rem; text-decoration:none; margin-bottom:1rem; }
.back-link:hover { color:var(--color-primary); }
.loc-header { display:flex; gap:1rem; align-items:flex-start; margin-bottom:1.25rem; flex-wrap:wrap; }
.loc-avatar { width:96px; height:96px; border-radius:12px; background:var(--color-secondary); object-fit:cover; flex-shrink:0; display:flex; align-items:center; justify-content:center; font-size:2.5rem; overflow:hidden; border:2px solid var(--color-secondary); }
.loc-avatar img { width:100%; height:100%; object-fit:cover; }
.loc-meta { flex:1; min-width:0; }
.loc-name { font-size:1.4rem; font-weight:700; margin:0 0 0.3rem; }
.loc-city { color:var(--color-muted); font-size:0.88rem; margin-bottom:0.4rem; }
.loc-desc { font-size:0.93rem; line-height:1.55; white-space:pre-wrap; word-break:break-word; margin-top:0.5rem; }
.section-title { font-size:1rem; font-weight:700; margin:1.5rem 0 0.75rem; display:flex; align-items:center; justify-content:space-between; }
.hours-table { width:100%; border-collapse:collapse; font-size:0.88rem; }
.hours-table td { padding:0.3rem 0.5rem; border-bottom:1px solid var(--color-secondary); }
.hours-table td:first-child { font-weight:500; width:100px; }
.hours-closed { color:var(--color-muted); }
.gallery-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(120px,1fr)); gap:0.6rem; }
.gallery-img-wrap { position:relative; aspect-ratio:1; border-radius:8px; overflow:hidden; background:var(--color-secondary); }
.gallery-img-wrap img { width:100%; height:100%; object-fit:cover; cursor:pointer; transition:opacity 0.15s; }
.gallery-img-wrap img:hover { opacity:0.88; }
.gallery-del-btn { position:absolute; top:4px; right:4px; background:rgba(0,0,0,.6); border:none; color:#fff; border-radius:50%; width:22px; height:22px; font-size:0.7rem; cursor:pointer; display:flex; align-items:center; justify-content:center; padding:0; margin:0; }
.gallery-upload-btn { aspect-ratio:1; border:2px dashed var(--color-secondary); border-radius:8px; display:flex; align-items:center; justify-content:center; font-size:1.5rem; color:var(--color-muted); cursor:pointer; transition:border-color 0.15s; background:none; }
.gallery-upload-btn:hover { border-color:var(--color-primary); color:var(--color-primary); }
.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; cursor:pointer; }
.event-card:hover { border-color:var(--color-primary); }
.event-card-img { width:64px; height:64px; border-radius:8px; object-fit:cover; background:var(--color-secondary); flex-shrink:0; overflow:hidden; display:flex; align-items:center; justify-content:center; font-size:1.4rem; }
.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.92rem; margin:0 0 0.2rem; }
.event-card-date { font-size:0.78rem; color:var(--color-muted); }
.event-card-attendees { font-size:0.78rem; color:var(--color-muted); }
/* Modal */
.modal-overlay { display:none; position:fixed; inset:0; background:rgba(0,0,0,.6); z-index:200; align-items:center; justify-content:center; }
.modal-overlay.open { display:flex; }
.modal { background:var(--color-card); border-radius:12px; width:min(520px,95vw); max-height:90vh; overflow-y:auto; padding:1.5rem; }
.modal h3 { margin:0 0 1rem; }
.modal-footer { display:flex; gap:0.75rem; justify-content:flex-end; margin-top:1.25rem; flex-wrap:wrap; }
.hours-grid { display:grid; grid-template-columns:auto 1fr 1fr auto; gap:0.4rem 0.5rem; align-items:center; font-size:0.85rem; margin-top:0.5rem; }
.img-preview { width:80px; height:80px; border-radius:8px; object-fit:cover; background:var(--color-secondary); display:flex; align-items:center; justify-content:center; font-size:1.5rem; flex-shrink:0; overflow:hidden; border:1px solid var(--color-secondary); }
.img-preview img { width:100%; height:100%; object-fit:cover; }
.img-row { display:flex; gap:0.75rem; align-items:center; margin-bottom:0.5rem; }
.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:50; display:none; list-style:none; margin:0; padding:0; max-height:220px; overflow-y:auto; }
/* Lightbox */
.lb { display:none; position:fixed; inset:0; background:rgba(0,0,0,.9); z-index:300; align-items:center; justify-content:center; }
.lb.open { display:flex; }
.lb img { max-width:95vw; max-height:95vh; border-radius:8px; object-fit:contain; }
.lb-close { position:absolute; top:1rem; right:1rem; background:none; border:none; color:#fff; font-size:1.5rem; cursor:pointer; }
.owner-badge { display:inline-flex; align-items:center; gap:0.3rem; font-size:0.75rem; background:var(--color-secondary); border-radius:4px; padding:0.2rem 0.5rem; color:var(--color-muted); margin-top:0.3rem; }
.owner-actions { display:flex; gap:0.5rem; flex-wrap:wrap; margin-top:0.75rem; }
</style>
</head>
<body>
<div class="main">
<a href="/community/locations.html" class="back-link">← Locations</a>
<div id="content">
<p style="color:var(--color-muted);">Wird geladen…</p>
</div>
</div>
<!-- ── Edit-Modal ──────────────────────────────────────────────────────────── -->
<div class="modal-overlay" id="editModal">
<div class="modal">
<h3>Location bearbeiten</h3>
<div class="img-row">
<div class="img-preview" id="editPicPreview">📍</div>
<div>
<label class="btn" style="display:inline-block;cursor:pointer;font-size:0.85rem;">
Profilbild ändern
<input type="file" id="editPicFile" accept="image/*" style="display:none;" onchange="onEditPicChange(this)">
</label>
</div>
</div>
<label>Name *</label>
<input type="text" id="editName" maxlength="200">
<label>Beschreibung <span style="color:var(--color-muted);font-size:0.8rem;">(max. 1000 Zeichen)</span></label>
<textarea id="editDesc" maxlength="1000" rows="4" style="resize:vertical;width:100%;box-sizing:border-box;padding:0.65rem 0.9rem;border:1px solid var(--color-secondary);border-radius:6px;background:var(--color-secondary);color:var(--color-text);font-size:1rem;outline:none;font-family:inherit;transition:border-color 0.2s;" onfocus="this.style.borderColor='var(--color-primary)'" onblur="this.style.borderColor='var(--color-secondary)'"></textarea>
<label>Adresse</label>
<div id="editStadtRow">
<div style="position:relative;">
<input type="text" id="editCity" placeholder="Straße, Hausnummer, Stadt…" autocomplete="off"
style="width:100%;box-sizing:border-box;padding:0.55rem 2rem 0.55rem 0.8rem;border:1px solid var(--color-secondary);border-radius:6px;background:var(--color-secondary);color:var(--color-text);font-size:0.95rem;outline:none;"
oninput="onEditCityInput()">
<button id="editCityClear" onclick="clearEditCity()" 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>
<ul id="editCitySuggestions" style="display:none;position:absolute;top:100%;left:0;right:0;
background:var(--color-card);border:1px solid var(--color-secondary);border-radius:6px;
z-index:100;list-style:none;margin:0.2rem 0 0;padding:0;max-height:200px;overflow-y:auto;"></ul>
</div>
<div id="editLocMsg" style="font-size:0.82rem;color:var(--color-muted);margin-top:0.25rem;min-height:1.1em;"></div>
</div>
<label style="margin-top:0.75rem;display:block;">Öffnungszeiten</label>
<div class="hours-grid" id="editHoursGrid"></div>
<div class="modal-footer">
<button class="btn" style="background:var(--color-secondary);color:var(--color-text);" onclick="closeEditModal()">Abbrechen</button>
<button class="btn" id="editSubmitBtn" onclick="submitEdit()">Speichern</button>
</div>
</div>
</div>
<!-- ── Event erstellen/bearbeiten Modal ───────────────────────────────────── -->
<div class="modal-overlay" id="eventModal">
<div class="modal">
<h3 id="eventModalTitle">Veranstaltung erstellen</h3>
<div class="img-row">
<div class="img-preview" id="eventPicPreview">🗓</div>
<div>
<label class="btn" style="display:inline-block;cursor:pointer;font-size:0.85rem;">
Bild wählen
<input type="file" id="eventPicFile" accept="image/*" style="display:none;" onchange="onEventPicChange(this)">
</label>
</div>
</div>
<label>Titel *</label>
<input type="text" id="eventTitle" maxlength="200">
<label>Beschreibung <span style="color:var(--color-muted);font-size:0.8rem;">(max. 1000 Zeichen)</span></label>
<textarea id="eventDesc" maxlength="1000" rows="4" style="resize:vertical;"></textarea>
<label>Datum &amp; Uhrzeit *</label>
<input type="datetime-local" id="eventStartAt">
<div class="modal-footer">
<button class="btn" style="background:var(--color-secondary);color:var(--color-text);" onclick="closeEventModal()">Abbrechen</button>
<button class="btn" id="eventSubmitBtn" onclick="submitEvent()">Speichern</button>
</div>
</div>
</div>
<!-- ── Galerie Lightbox ───────────────────────────────────────────────────── -->
<div class="lb" id="lightbox" onclick="closeLightbox()">
<button class="lb-close" onclick="closeLightbox()"></button>
<img id="lbImg" src="" alt="">
</div>
<script src="/js/sidebar.js"></script>
<script src="/js/icons.js"></script>
<script>
const params = new URLSearchParams(location.search);
const locationId = params.get('id');
let locDetail = null;
let myUserId = null;
let isOwner = false;
let isFollowing = false;
// ── Bild-Resize ───────────────────────────────────────────────────────────────
function resizeImage(file, maxPx, quality) {
return new Promise((resolve, reject) => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(url);
let w = img.naturalWidth, h = img.naturalHeight;
if (w > maxPx || h > maxPx) {
if (w >= h) { h = Math.max(1, Math.round(maxPx * h / w)); w = maxPx; }
else { w = Math.max(1, Math.round(maxPx * w / h)); h = maxPx; }
}
const canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
resolve(canvas.toDataURL('image/jpeg', quality || 0.85).split(',')[1]);
};
img.onerror = reject;
img.src = url;
});
}
function escHtml(s) {
return (s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
const DAY_NAMES = ['Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag','Sonntag'];
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';
}
// ── Lade Seite ────────────────────────────────────────────────────────────────
async function loadPage() {
if (!locationId) { document.getElementById('content').innerHTML = '<p>Keine Location-ID angegeben.</p>'; return; }
const [meRes, locRes] = await Promise.all([
fetch('/login/me'),
fetch(`/locations/${locationId}`)
]);
if (!locRes.ok) { document.getElementById('content').innerHTML = '<p>Location nicht gefunden.</p>'; return; }
if (meRes.ok) {
const me = await meRes.json();
myUserId = me.userId;
}
locDetail = await locRes.json();
isOwner = locDetail.ownerId === myUserId;
isFollowing = !!locDetail.following;
renderPage();
loadEvents();
}
function renderPage() {
const loc = locDetail;
const imgHtml = loc.profilePictureHq || loc.profilePictureLq
? `<img src="data:image/jpeg;base64,${loc.profilePictureHq || loc.profilePictureLq}" alt="${escHtml(loc.name)}">`
: '📍';
const ownerActions = isOwner ? `
<div class="owner-actions">
<button class="btn" style="font-size:0.85rem;" onclick="openEditModal()">✎ Bearbeiten</button>
<button class="btn" style="background:var(--color-secondary);color:var(--color-text);font-size:0.85rem;" onclick="deleteLocation()">Löschen</button>
</div>` : `
<div class="owner-actions">
<button class="btn" id="followBtn" style="font-size:0.85rem;${isFollowing ? 'background:var(--color-primary);color:#fff;' : 'background:var(--color-secondary);color:var(--color-text);'}" onclick="toggleFollow()">
${isFollowing ? '★ Abonniert' : '☆ Abonnieren'}
</button>
</div>`;
let hoursHtml = '';
if (loc.openingHours && loc.openingHours.length > 0) {
const rowsHtml = loc.openingHours.map(h => {
const dayName = DAY_NAMES[h.dayOfWeek - 1] || '';
const timeText = h.closed
? '<span class="hours-closed">Geschlossen</span>'
: `${h.openTime || '--:--'} ${h.closeTime || '--:--'}`;
return `<tr><td>${dayName}</td><td>${timeText}</td></tr>`;
}).join('');
hoursHtml = `
<div class="section-title">Öffnungszeiten</div>
<table class="hours-table"><tbody>${rowsHtml}</tbody></table>`;
}
const galleryHtml = buildGalleryHtml(loc.gallery || []);
document.getElementById('content').innerHTML = `
<div class="loc-header">
<div class="loc-avatar">${imgHtml}</div>
<div class="loc-meta">
<div class="loc-name">${escHtml(loc.name)}</div>
${(loc.street || loc.city) ? `<div class="loc-city">📍 ${escHtml([loc.street, loc.city].filter(Boolean).join(', '))}</div>` : ''}
${loc.description ? `<div class="loc-desc">${escHtml(loc.description)}</div>` : ''}
${ownerActions}
</div>
</div>
${hoursHtml}
<div class="section-title">
Galerie
${isOwner ? `<label class="btn" style="font-size:0.8rem;cursor:pointer;">
+ Bild hinzufügen
<input type="file" accept="image/*" style="display:none;" onchange="uploadGalleryImage(this)">
</label>` : ''}
</div>
<div class="gallery-grid" id="galleryGrid">${galleryHtml}</div>
<div class="section-title">
Veranstaltungen
${isOwner ? `<button class="btn" style="font-size:0.8rem;" onclick="openEventModal()">+ Veranstaltung erstellen</button>` : ''}
</div>
<div class="event-list" id="eventList"><p style="color:var(--color-muted);font-size:0.9rem;">Wird geladen…</p></div>
`;
}
function buildGalleryHtml(gallery) {
return gallery.map(img => `
<div class="gallery-img-wrap">
<img src="data:image/jpeg;base64,${img.imageData}" alt="Galeriebild"
onclick="openLightbox(this.src)">
${isOwner ? `<button class="gallery-del-btn" onclick="deleteGalleryImage('${img.imageId}')">✕</button>` : ''}
</div>`).join('');
}
// ── Galerie ────────────────────────────────────────────────────────────────────
async function uploadGalleryImage(input) {
const file = input.files[0];
if (!file) return;
try {
const imageData = await resizeImage(file, 1024, 0.88);
const res = await fetch(`/locations/${locationId}/gallery`, {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ imageData })
});
if (res.status === 422) { alert('Maximal 20 Galeriebilder erlaubt.'); return; }
if (!res.ok) throw new Error();
const img = await res.json();
const grid = document.getElementById('galleryGrid');
grid.insertAdjacentHTML('beforeend', `
<div class="gallery-img-wrap">
<img src="data:image/jpeg;base64,${img.imageData}" alt="Galeriebild" onclick="openLightbox(this.src)">
<button class="gallery-del-btn" onclick="deleteGalleryImage('${img.imageId}')">✕</button>
</div>`);
} catch { alert('Fehler beim Hochladen.'); }
input.value = '';
}
async function deleteGalleryImage(imageId) {
if (!confirm('Bild löschen?')) return;
const res = await fetch(`/locations/${locationId}/gallery/${imageId}`, { method: 'DELETE' });
if (!res.ok) { alert('Fehler beim Löschen.'); return; }
await loadPage();
}
// ── Events ─────────────────────────────────────────────────────────────────────
async function loadEvents() {
const res = await fetch(`/locations/${locationId}/events`);
if (!res.ok) return;
const events = await res.json();
const list = document.getElementById('eventList');
if (!list) return;
if (events.length === 0) {
list.innerHTML = '<p style="color:var(--color-muted);font-size:0.9rem;">Noch keine Veranstaltungen.</p>';
return;
}
list.innerHTML = events.map(e => {
const imgHtml = e.imageData
? `<img src="data:image/jpeg;base64,${e.imageData}" alt="${escHtml(e.title)}">`
: '🗓';
const deleteBtn = isOwner
? `<button class="btn" style="font-size:0.75rem;margin-top:0.3rem;background:var(--color-secondary);color:var(--color-text);padding:0.2rem 0.5rem;" onclick="event.preventDefault();deleteEvent('${e.eventId}')">Löschen</button>`
: '';
return `
<a class="event-card" href="/community/event-detail.html?id=${e.eventId}">
<div class="event-card-img">${imgHtml}</div>
<div class="event-card-body">
<div class="event-card-title">${escHtml(e.title)}</div>
<div class="event-card-date">${formatDate(e.startAt)}</div>
<div class="event-card-attendees">${e.attendeeCount} Teilnehmer*in(nen)${e.attendingMe ? ' · Du nimmst teil' : ''}</div>
${deleteBtn}
</div>
</a>`;
}).join('');
}
// ── Lightbox ───────────────────────────────────────────────────────────────────
function openLightbox(src) {
document.getElementById('lbImg').src = src;
document.getElementById('lightbox').classList.add('open');
}
function closeLightbox() { document.getElementById('lightbox').classList.remove('open'); }
// ── Edit Modal ─────────────────────────────────────────────────────────────────
let _editLq = null, _editHq = null, _editLat = null, _editLon = null, _editStreet = null, _editCity = null, _editCityTimer = null;
function buildHoursGrid(gridId, existing) {
const grid = document.getElementById(gridId);
grid.innerHTML = '';
const byDay = {};
(existing || []).forEach(h => { byDay[h.dayOfWeek] = h; });
DAY_NAMES.forEach((name, i) => {
const day = i + 1;
const h = byDay[day] || {};
grid.insertAdjacentHTML('beforeend', `
<span>${name}</span>
<input type="time" id="open_${day}_${gridId}" value="${h.openTime || ''}" style="font-size:0.82rem;padding:0.25rem 0.4rem;">
<input type="time" id="close_${day}_${gridId}" value="${h.closeTime || ''}" style="font-size:0.82rem;padding:0.25rem 0.4rem;">
<label style="display:flex;align-items:center;gap:0.25rem;font-size:0.82rem;white-space:nowrap;">
<input type="checkbox" id="closed_${day}_${gridId}" ${h.closed ? 'checked' : ''}> Geschlossen
</label>
`);
});
}
function collectHours(gridId) {
const result = [];
for (let d = 1; d <= 7; d++) {
const open = document.getElementById(`open_${d}_${gridId}`)?.value;
const close = document.getElementById(`close_${d}_${gridId}`)?.value;
const closed = document.getElementById(`closed_${d}_${gridId}`)?.checked;
if (open || close || closed) {
result.push({ dayOfWeek: d, openTime: open || null, closeTime: close || null, closed: !!closed });
}
}
return result;
}
function openEditModal() {
const loc = locDetail;
_editLq = null; _editHq = null;
_editLat = loc.lat; _editLon = loc.lon;
_editStreet = loc.street || null; _editCity = loc.city || null;
document.getElementById('editName').value = loc.name || '';
document.getElementById('editDesc').value = loc.description || '';
const cityInp = document.getElementById('editCity');
const addressLabel = [loc.street, loc.city].filter(Boolean).join(', ');
cityInp.value = addressLabel;
cityInp.readOnly = !!addressLabel;
document.getElementById('editCityClear').style.display = addressLabel ? '' : 'none';
document.getElementById('editLocMsg').textContent = '';
const picSrc = loc.profilePictureHq || loc.profilePictureLq;
document.getElementById('editPicPreview').innerHTML = picSrc
? `<img src="data:image/jpeg;base64,${picSrc}" alt="Vorschau">`
: '📍';
buildHoursGrid('editHoursGrid', loc.openingHours || []);
document.getElementById('editModal').classList.add('open');
}
function closeEditModal() { document.getElementById('editModal').classList.remove('open'); }
async function onEditPicChange(input) {
const file = input.files[0]; if (!file) return;
_editLq = await resizeImage(file, 120, 0.75);
_editHq = await resizeImage(file, 1024, 0.88);
document.getElementById('editPicPreview').innerHTML = `<img src="data:image/jpeg;base64,${_editHq}" alt="Vorschau">`;
}
function onEditCityInput() {
const q = document.getElementById('editCity').value.trim();
_editLat = null; _editLon = null; _editStreet = null; _editCity = null;
document.getElementById('editCityClear').style.display = 'none';
clearTimeout(_editCityTimer);
if (q.length < 2) { document.getElementById('editCitySuggestions').style.display = 'none'; return; }
_editCityTimer = setTimeout(() => fetchAddressSuggestions(q), 300);
}
function fmtAddress(r) {
const road = r.address.road || r.address.pedestrian || r.address.path || '';
const hn = r.address.house_number || '';
const street = (road + (hn ? ' ' + hn : '')).trim();
const plz = r.address.postcode || '';
const city = r.address.city || r.address.town || r.address.village || r.address.county || '';
const parts = [];
if (street) parts.push(street);
const cityPart = (plz && city) ? plz + ' ' + city : (plz || city);
if (cityPart) parts.push(cityPart);
return { label: parts.join(', '), street, city };
}
async function fetchAddressSuggestions(q) {
try {
const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(q)}&format=json&addressdetails=1&limit=5`;
const res = await fetch(url, { headers: { 'Accept-Language': 'de' } });
if (!res.ok) return;
const results = await res.json();
const ul = document.getElementById('editCitySuggestions');
if (!results.length) { ul.style.display = 'none'; return; }
ul.innerHTML = results.map(r => {
const { label, street, city } = fmtAddress(r);
const esc = s => s.replace(/\\/g,'\\\\').replace(/'/g,"\\'");
return `<li style="padding:0.5rem 0.75rem;cursor:pointer;font-size:0.9rem;border-bottom:1px solid var(--color-secondary);"
onmousedown="selectEditAddress(event,'${esc(label)}','${esc(street)}','${esc(city)}',${parseFloat(r.lat)},${parseFloat(r.lon)})">${label}</li>`;
}).join('');
ul.style.display = '';
} catch (_) {}
}
function selectEditAddress(e, label, street, city, lat, lon) {
e.preventDefault();
const inp = document.getElementById('editCity');
inp.value = label; inp.readOnly = true;
_editStreet = street || null;
_editCity = city || null;
_editLat = lat; _editLon = lon;
document.getElementById('editCityClear').style.display = '';
document.getElementById('editCitySuggestions').style.display = 'none';
document.getElementById('editLocMsg').textContent = '';
}
function clearEditCity() {
const inp = document.getElementById('editCity');
inp.value = ''; inp.readOnly = false;
_editLat = null; _editLon = null; _editStreet = null; _editCity = null;
document.getElementById('editCityClear').style.display = 'none';
document.getElementById('editLocMsg').textContent = '';
inp.focus();
}
document.addEventListener('click', e => {
if (!e.target.closest('#editStadtRow')) document.getElementById('editCitySuggestions').style.display = 'none';
});
async function submitEdit() {
const name = document.getElementById('editName').value.trim();
if (!name) { alert('Name darf nicht leer sein.'); return; }
const addrVal = document.getElementById('editCity').value.trim();
if (addrVal && _editLat == null) {
document.getElementById('editLocMsg').textContent = 'Bitte eine Adresse aus der Vorschlagsliste auswählen oder per GPS ermitteln.';
document.getElementById('editCity').focus();
return;
}
const btn = document.getElementById('editSubmitBtn');
btn.disabled = true; btn.textContent = 'Wird gespeichert…';
try {
const body = {
name,
description: document.getElementById('editDesc').value.trim() || null,
street: _editStreet,
city: _editCity,
lat: _editLat,
lon: _editLon
};
if (_editLq) body.profilePictureLq = _editLq;
if (_editHq) body.profilePictureHq = _editHq;
const res = await fetch(`/locations/${locationId}`, {
method: 'PUT',
headers: {'Content-Type':'application/json'},
body: JSON.stringify(body)
});
if (!res.ok) throw new Error();
const hours = collectHours('editHoursGrid');
await fetch(`/locations/${locationId}/opening-hours`, {
method: 'PUT',
headers: {'Content-Type':'application/json'},
body: JSON.stringify(hours)
});
locDetail = await (await fetch(`/locations/${locationId}`)).json();
closeEditModal();
renderPage();
loadEvents();
} catch { alert('Fehler beim Speichern.'); }
finally { btn.disabled = false; btn.textContent = 'Speichern'; }
}
async function toggleFollow() {
const btn = document.getElementById('followBtn');
if (btn) btn.disabled = true;
try {
const res = await fetch(`/locations/${locationId}/follow`, { method: 'POST' });
if (!res.ok) throw new Error();
const data = await res.json();
isFollowing = data.following;
if (btn) {
btn.textContent = isFollowing ? '★ Abonniert' : '☆ Abonnieren';
btn.style.background = isFollowing ? 'var(--color-primary)' : 'var(--color-secondary)';
btn.style.color = isFollowing ? '#fff' : 'var(--color-text)';
}
} catch (_) { alert('Fehler beim Aktualisieren des Abonnements.'); }
finally { if (btn) btn.disabled = false; }
}
async function deleteLocation() {
if (!confirm('Location wirklich löschen? Alle Veranstaltungen und Galeriebilder werden ebenfalls gelöscht.')) return;
const res = await fetch(`/locations/${locationId}`, { method: 'DELETE' });
if (!res.ok) { alert('Fehler beim Löschen.'); return; }
window.location.href = '/community/locations.html';
}
// ── Event Modal ────────────────────────────────────────────────────────────────
let _evtImg = null, _editEventId = null;
function openEventModal(evtId) {
_editEventId = evtId || null;
_evtImg = null;
document.getElementById('eventModalTitle').textContent = evtId ? 'Veranstaltung bearbeiten' : 'Veranstaltung erstellen';
document.getElementById('eventTitle').value = '';
document.getElementById('eventDesc').value = '';
document.getElementById('eventStartAt').value = '';
document.getElementById('eventPicPreview').innerHTML = '🗓';
document.getElementById('eventModal').classList.add('open');
}
function closeEventModal() { document.getElementById('eventModal').classList.remove('open'); }
async function onEventPicChange(input) {
const file = input.files[0]; if (!file) return;
_evtImg = await resizeImage(file, 1024, 0.88);
document.getElementById('eventPicPreview').innerHTML = `<img src="data:image/jpeg;base64,${_evtImg}" alt="Vorschau">`;
}
async function submitEvent() {
const title = document.getElementById('eventTitle').value.trim();
const startAt = document.getElementById('eventStartAt').value;
if (!title) { alert('Bitte gib einen Titel ein.'); return; }
if (!startAt) { alert('Bitte wähle Datum und Uhrzeit.'); return; }
const btn = document.getElementById('eventSubmitBtn');
btn.disabled = true; btn.textContent = 'Wird gespeichert…';
try {
const body = {
title,
description: document.getElementById('eventDesc').value.trim() || null,
imageData: _evtImg,
startAt: startAt + ':00'
};
const url = _editEventId
? `/locations/${locationId}/events/${_editEventId}`
: `/locations/${locationId}/events`;
const method = _editEventId ? 'PUT' : 'POST';
const res = await fetch(url, {
method,
headers: {'Content-Type':'application/json'},
body: JSON.stringify(body)
});
if (!res.ok) throw new Error();
closeEventModal();
loadEvents();
} catch { alert('Fehler beim Speichern.'); }
finally { btn.disabled = false; btn.textContent = 'Speichern'; }
}
async function deleteEvent(eventId) {
if (!confirm('Veranstaltung löschen?')) return;
const res = await fetch(`/locations/${locationId}/events/${eventId}`, { method: 'DELETE' });
if (!res.ok) { alert('Fehler beim Löschen.'); return; }
loadEvents();
}
// ── Init ──────────────────────────────────────────────────────────────────────
loadPage();
</script>
</body>
</html>

View File

@@ -0,0 +1,647 @@
<!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>Locations xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.tabs { display:flex; gap:0; align-items:center; margin-bottom:1.25rem; border-bottom:1px solid var(--color-secondary); }
.tab-btn { background:none; border:none; border-bottom:3px solid transparent; border-radius:0; padding:0.6rem 1.4rem; font-size:0.95rem; font-weight:600; color:var(--color-muted); cursor:pointer; margin-bottom:-1px; transition:color 0.15s,border-color 0.15s; width:auto; margin-top:0; }
.tab-btn:hover { color:var(--color-text); background:none; }
.tab-btn.active { color:var(--color-primary); border-bottom-color:var(--color-primary); }
.tab-panel { display:none; }
.tab-panel.active { display:block; }
/* ── 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 h3 { 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-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%; }
.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; }
.search-bar { display:flex; gap:0.5rem; flex-wrap:wrap; align-items:flex-end; margin-bottom:1.25rem; }
.search-bar .input-wrap { flex:1; min-width:160px; position:relative; }
.search-bar input, .search-bar select { width:100%; box-sizing:border-box; }
.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:50; display:none; list-style:none; margin:0; padding:0; max-height:220px; overflow-y:auto; }
.loc-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(180px,1fr)); gap:1rem; }
.loc-card { background:var(--color-card); border:1px solid var(--color-secondary); border-radius:10px; overflow:hidden; cursor:pointer; transition:border-color 0.15s, box-shadow 0.15s; text-decoration:none; color:inherit; display:block; }
.loc-card:hover { border-color:var(--color-primary); box-shadow:0 2px 8px rgba(0,0,0,.15); }
.loc-card-img { width:100%; aspect-ratio:1; object-fit:cover; background:var(--color-secondary); display:flex; align-items:center; justify-content:center; font-size:2rem; }
.loc-card-img img { width:100%; height:100%; object-fit:cover; }
.loc-card-body { padding:0.6rem 0.75rem; }
.loc-card-name { font-weight:600; font-size:0.9rem; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.loc-card-dist { font-size:0.75rem; color:var(--color-muted); margin-top:0.1rem; }
.loc-card-skeleton { background:var(--color-secondary); border-radius:10px; aspect-ratio:1; animation:pulse 1.4s infinite; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.5} }
.empty-hint { color:var(--color-muted); font-size:0.9rem; margin-top:0.5rem; }
.sentinel { height:1px; }
/* Modal */
.modal-overlay { display:none; position:fixed; inset:0; background:rgba(0,0,0,.6); z-index:200; align-items:center; justify-content:center; }
.modal-overlay.open { display:flex; }
.modal { background:var(--color-card); border-radius:12px; width:min(520px,95vw); max-height:90vh; overflow-y:auto; padding:1.5rem; }
.modal h3 { margin:0 0 1rem; }
.modal-footer { display:flex; gap:0.75rem; justify-content:flex-end; margin-top:1.25rem; flex-wrap:wrap; }
.disclaimer-box { background:rgba(var(--color-primary-rgb,180,0,60),.08); border:1px solid var(--color-primary); border-radius:8px; padding:0.85rem 1rem; font-size:0.88rem; margin:0.75rem 0; }
.disclaimer-box label { display:flex; gap:0.5rem; align-items:flex-start; cursor:pointer; margin:0; color:var(--color-text); font-size:0.88rem; }
.disclaimer-box input[type="checkbox"] { width:auto; padding:0; border:none; background:none; flex-shrink:0; margin-top:0.15rem; cursor:pointer; }
.hours-grid { display:grid; grid-template-columns:auto 1fr 1fr auto; gap:0.4rem 0.5rem; align-items:center; font-size:0.85rem; margin-top:0.5rem; }
.hours-grid span { white-space:nowrap; }
.img-preview { width:80px; height:80px; border-radius:8px; object-fit:cover; background:var(--color-secondary); display:flex; align-items:center; justify-content:center; font-size:1.5rem; flex-shrink:0; overflow:hidden; border:1px solid var(--color-secondary); }
.img-preview img { width:100%; height:100%; object-fit:cover; }
.img-row { display:flex; gap:0.75rem; align-items:center; margin-bottom:0.5rem; }
</style>
</head>
<body class="app">
<!-- ── Autocomplete (außerhalb des transformierten Drawers) ── -->
<ul id="filterCitySuggestions" 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-Drawer ── -->
<div class="filter-overlay-bg" id="filterBg"></div>
<div class="filter-drawer" id="filterDrawer">
<div class="filter-drawer-header">
<h3>Filter</h3>
<button class="filter-close-btn" id="filterCloseBtn" aria-label="Filter schließen"></button>
</div>
<div class="filter-drawer-body">
<div class="filter-group">
<label>Ort</label>
<div style="position:relative;" id="filterCityRow">
<input type="text" id="filterCity" placeholder="Stadt suchen und auswählen…" autocomplete="off"
style="padding-right:2rem;" oninput="onFilterCityInput()">
<button id="filterCityClear" onclick="clearFilterCity()" 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="tabs">
<button class="tab-btn active" onclick="switchTab('search',this)">Suchen</button>
<button class="tab-btn" onclick="switchTab('mine',this)">Meine Locations</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>
<!-- ── Suche ────────────────────────────────────────────────────── -->
<div id="paneSearch" class="tab-panel active">
<div class="loc-grid" id="searchGrid"></div>
<p class="empty-hint" id="searchEmpty" style="display:none;">Keine Locations in diesem Umkreis gefunden.</p>
<p class="empty-hint" id="searchHint" style="display:none;">Wähle im Filter einen Ort, um Locations in deiner Nähe zu suchen.</p>
<div class="sentinel" id="searchSentinel"></div>
</div>
<!-- ── Meine Locations ──────────────────────────────────────────── -->
<div id="paneMine" class="tab-panel">
<div style="display:flex; justify-content:flex-end; margin-bottom:1rem;">
<button class="btn" onclick="openCreateModal()">+ Location anlegen</button>
</div>
<div class="loc-grid" id="mineGrid"></div>
<p class="empty-hint" id="mineEmpty" style="display:none;">Du hast noch keine Locations angelegt.</p>
</div>
</div>
</div>
<!-- ── Erstellen-Modal ──────────────────────────────────────────────── -->
<div class="modal-overlay" id="createModal">
<div class="modal">
<h3>Location anlegen</h3>
<div class="img-row">
<div class="img-preview" id="createPicPreview">📍</div>
<div>
<label class="btn" style="display:inline-block;cursor:pointer;font-size:0.85rem;">
Profilbild wählen
<input type="file" id="createPicFile" accept="image/*" style="display:none;" onchange="onCreatePicChange(this)">
</label>
</div>
</div>
<label>Name *</label>
<input type="text" id="createName" maxlength="200" placeholder="Name der Location">
<label>Beschreibung <span style="color:var(--color-muted);font-size:0.8rem;">(max. 1000 Zeichen)</span></label>
<textarea id="createDesc" maxlength="1000" rows="4" style="resize:vertical;width:100%;box-sizing:border-box;padding:0.65rem 0.9rem;border:1px solid var(--color-secondary);border-radius:6px;background:var(--color-secondary);color:var(--color-text);font-size:1rem;outline:none;font-family:inherit;transition:border-color 0.2s;" onfocus="this.style.borderColor='var(--color-primary)'" onblur="this.style.borderColor='var(--color-secondary)'"></textarea>
<label>Adresse *</label>
<div id="createStadtRow">
<div style="position:relative;">
<input type="text" id="createCity" placeholder="Straße, Hausnummer, Stadt…" autocomplete="off"
style="width:100%;box-sizing:border-box;padding:0.55rem 2rem 0.55rem 0.8rem;border:1px solid var(--color-secondary);border-radius:6px;background:var(--color-secondary);color:var(--color-text);font-size:0.95rem;outline:none;"
oninput="onCreateCityInput()">
<button id="createCityClear" onclick="clearCreateCity()" 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>
<ul id="createCitySuggestions" style="display:none;position:absolute;top:100%;left:0;right:0;
background:var(--color-card);border:1px solid var(--color-secondary);border-radius:6px;
z-index:100;list-style:none;margin:0.2rem 0 0;padding:0;max-height:200px;overflow-y:auto;"></ul>
</div>
<div id="createLocMsg" style="font-size:0.82rem;color:var(--color-muted);margin-top:0.25rem;min-height:1.1em;"></div>
</div>
<label style="margin-top:0.75rem;display:block;">Öffnungszeiten <span style="color:var(--color-muted);font-size:0.8rem;">(optional)</span></label>
<div class="hours-grid" id="createHoursGrid"></div>
<div class="disclaimer-box">
<label>
<input type="checkbox" id="createOwnership">
<span>Ich bestätige, dass ich Eigentümer*in oder autorisierte*r Vertreter*in dieser Location bin und berechtigt bin, sie hier einzutragen.</span>
</label>
</div>
<div class="modal-footer">
<button class="btn" style="background:var(--color-secondary);color:var(--color-text);" onclick="closeCreateModal()">Abbrechen</button>
<button class="btn" id="createSubmitBtn" onclick="submitCreate()">Anlegen</button>
</div>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
<script>
// ── Bild-Resize ──────────────────────────────────────────────────────────────
function resizeImage(file, maxPx, quality) {
return new Promise((resolve, reject) => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(url);
let w = img.naturalWidth, h = img.naturalHeight;
if (w > maxPx || h > maxPx) {
if (w >= h) { h = Math.max(1, Math.round(maxPx * h / w)); w = maxPx; }
else { w = Math.max(1, Math.round(maxPx * w / h)); h = maxPx; }
}
const canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
resolve(canvas.toDataURL('image/jpeg', quality || 0.85).split(',')[1]);
};
img.onerror = reject;
img.src = url;
});
}
// ── Tabs ─────────────────────────────────────────────────────────────────────
function switchTab(name, btn) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
document.getElementById('pane' + name.charAt(0).toUpperCase() + name.slice(1)).classList.add('active');
}
// ── Filter-Drawer ─────────────────────────────────────────────────────────────
let savedFilterCity = null, savedFilterLat = null, savedFilterLon = null, savedFilterRadius = 50;
let _inputLat = null, _inputLon = null, _cityTimer = null;
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(); });
function onFilterCityInput() {
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('filterCitySuggestions').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('filterCitySuggestions');
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="selectFilterCity(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 selectFilterCity(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('filterCitySuggestions').style.display = 'none';
}
function clearFilterCity() {
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('filterCitySuggestions');
if (!e.target.closest('#filterCityRow') && !ul.contains(e.target)) ul.style.display = 'none';
});
document.getElementById('applyBtn').addEventListener('click', () => {
closeFilter();
const city = document.getElementById('filterCity').value.trim();
const radius = parseInt(document.getElementById('filterRadius').value);
savedFilterCity = city || null;
savedFilterLat = _inputLat;
savedFilterLon = _inputLon;
savedFilterRadius = radius;
saveFilterToDb(savedFilterCity, savedFilterLat, savedFilterLon, savedFilterRadius);
updateFilterBadge();
runSearch();
});
document.getElementById('resetBtn').addEventListener('click', () => {
clearFilterCity();
document.getElementById('filterRadius').value = 50;
document.getElementById('distVal').textContent = '50 km';
});
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 = savedFilterCity != null;
const badge = document.getElementById('filterBadge');
badge.textContent = active ? '1' : '';
badge.style.display = active ? 'inline-flex' : 'none';
}
// ── Suche ─────────────────────────────────────────────────────────────────────
const searchState = { allIds: null, loaded: 0, loading: false, lat: 0, lon: 0 };
const BATCH = 20;
async function runSearch() {
if (savedFilterLat === null) {
document.getElementById('searchGrid').innerHTML = '';
document.getElementById('searchEmpty').style.display = 'none';
document.getElementById('searchHint').style.display = '';
return;
}
document.getElementById('searchGrid').innerHTML = '';
document.getElementById('searchEmpty').style.display = 'none';
document.getElementById('searchHint').style.display = 'none';
const res = await fetch(`/locations/ids?lat=${savedFilterLat}&lon=${savedFilterLon}&maxDistanceKm=${savedFilterRadius}`);
if (!res.ok) return;
const data = await res.json();
searchState.allIds = data.ids;
searchState.loaded = 0;
searchState.loading = false;
searchState.lat = savedFilterLat;
searchState.lon = savedFilterLon;
if (data.ids.length === 0) { document.getElementById('searchEmpty').style.display = ''; return; }
loadNextBatch();
}
async function loadNextBatch() {
if (searchState.loading || searchState.allIds === null) return;
if (searchState.loaded >= searchState.allIds.length) return;
searchState.loading = true;
const slice = searchState.allIds.slice(searchState.loaded, searchState.loaded + BATCH);
const res = await fetch('/locations/batch', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ ids: slice, lat: searchState.lat, lon: searchState.lon })
});
if (res.ok) {
const previews = await res.json();
const grid = document.getElementById('searchGrid');
previews.forEach(p => {
const a = document.createElement('a');
a.className = 'loc-card';
a.href = `/community/location-detail.html?id=${p.locationId}`;
const imgHtml = p.profilePictureLq
? `<img src="data:image/jpeg;base64,${p.profilePictureLq}" alt="${escHtml(p.name)}">`
: '<span>📍</span>';
a.innerHTML = `
<div class="loc-card-img">${imgHtml}</div>
<div class="loc-card-body">
<div class="loc-card-name">${escHtml(p.name)}</div>
${p.distanzKm >= 0 ? `<div class="loc-card-dist">${p.distanzKm} km</div>` : ''}
</div>`;
grid.appendChild(a);
});
searchState.loaded += slice.length;
}
searchState.loading = false;
}
const observer = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) loadNextBatch();
}, { rootMargin: '300px' });
observer.observe(document.getElementById('searchSentinel'));
// ── Meine Locations ───────────────────────────────────────────────────────────
let mineIds = [];
async function loadMine() {
const res = await fetch('/locations/mine');
if (!res.ok) return;
const data = await res.json();
mineIds = data.ids;
document.getElementById('mineEmpty').style.display = mineIds.length === 0 ? '' : 'none';
if (mineIds.length === 0) return;
const batchRes = await fetch('/locations/batch', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ ids: mineIds, lat: 0, lon: 0 })
});
if (!batchRes.ok) return;
const previews = await batchRes.json();
const grid = document.getElementById('mineGrid');
grid.innerHTML = '';
previews.forEach(p => {
const a = document.createElement('a');
a.className = 'loc-card';
a.href = `/community/location-detail.html?id=${p.locationId}`;
const imgHtml = p.profilePictureLq
? `<img src="data:image/jpeg;base64,${p.profilePictureLq}" alt="${escHtml(p.name)}">`
: '<span>📍</span>';
a.innerHTML = `
<div class="loc-card-img">${imgHtml}</div>
<div class="loc-card-body">
<div class="loc-card-name">${escHtml(p.name)}</div>
</div>`;
grid.appendChild(a);
});
}
function escHtml(s) {
return (s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ── Create Modal ──────────────────────────────────────────────────────────────
let _createLq = null, _createHq = null;
let _createLat = null, _createLon = null;
let _createStreet = null, _createCity = null;
let _createCityTimer = null;
const DAY_NAMES = ['Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag','Sonntag'];
function buildHoursGrid(gridId) {
const grid = document.getElementById(gridId);
grid.innerHTML = '';
DAY_NAMES.forEach((name, i) => {
const day = i + 1;
grid.insertAdjacentHTML('beforeend', `
<span>${name}</span>
<input type="time" id="open_${day}" placeholder="--:--" style="font-size:0.82rem;padding:0.25rem 0.4rem;">
<input type="time" id="close_${day}" placeholder="--:--" style="font-size:0.82rem;padding:0.25rem 0.4rem;">
<label style="display:flex;align-items:center;gap:0.25rem;font-size:0.82rem;white-space:nowrap;"><input type="checkbox" id="closed_${day}"> Geschlossen</label>
`);
});
}
function openCreateModal() {
_createLq = null; _createHq = null;
_createLat = null; _createLon = null; _createStreet = null; _createCity = null;
document.getElementById('createName').value = '';
document.getElementById('createDesc').value = '';
document.getElementById('createCity').value = '';
document.getElementById('createCity').readOnly = false;
document.getElementById('createCityClear').style.display = 'none';
document.getElementById('createLocMsg').textContent = '';
document.getElementById('createPicPreview').innerHTML = '📍';
document.getElementById('createOwnership').checked = false;
buildHoursGrid('createHoursGrid');
document.getElementById('createModal').classList.add('open');
}
function closeCreateModal() { document.getElementById('createModal').classList.remove('open'); }
async function onCreatePicChange(input) {
const file = input.files[0];
if (!file) return;
_createLq = await resizeImage(file, 120, 0.75);
_createHq = await resizeImage(file, 1024, 0.88);
const preview = document.getElementById('createPicPreview');
preview.innerHTML = `<img src="data:image/jpeg;base64,${_createHq}" alt="Vorschau">`;
}
function onCreateCityInput() {
const q = document.getElementById('createCity').value.trim();
_createLat = null; _createLon = null; _createStreet = null; _createCity = null;
document.getElementById('createCityClear').style.display = 'none';
clearTimeout(_createCityTimer);
if (q.length < 2) { document.getElementById('createCitySuggestions').style.display = 'none'; return; }
_createCityTimer = setTimeout(() => fetchAddressSuggestions(q), 300);
}
function fmtAddress(r) {
const road = r.address.road || r.address.pedestrian || r.address.path || '';
const hn = r.address.house_number || '';
const street = (road + (hn ? ' ' + hn : '')).trim();
const plz = r.address.postcode || '';
const city = r.address.city || r.address.town || r.address.village || r.address.county || '';
const parts = [];
if (street) parts.push(street);
const cityPart = (plz && city) ? plz + ' ' + city : (plz || city);
if (cityPart) parts.push(cityPart);
return { label: parts.join(', '), street, city };
}
async function fetchAddressSuggestions(q) {
try {
const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(q)}&format=json&addressdetails=1&limit=5`;
const res = await fetch(url, { headers: { 'Accept-Language': 'de' } });
if (!res.ok) return;
const results = await res.json();
const ul = document.getElementById('createCitySuggestions');
if (!results.length) { ul.style.display = 'none'; return; }
ul.innerHTML = results.map(r => {
const { label, street, city } = fmtAddress(r);
const esc = s => s.replace(/\\/g,'\\\\').replace(/'/g,"\\'");
return `<li style="padding:0.5rem 0.75rem;cursor:pointer;font-size:0.9rem;border-bottom:1px solid var(--color-secondary);"
onmousedown="selectCreateAddress(event,'${esc(label)}','${esc(street)}','${esc(city)}',${parseFloat(r.lat)},${parseFloat(r.lon)})">${label}</li>`;
}).join('');
ul.style.display = '';
} catch (_) {}
}
function selectCreateAddress(e, label, street, city, lat, lon) {
e.preventDefault();
const inp = document.getElementById('createCity');
inp.value = label; inp.readOnly = true;
_createStreet = street || null;
_createCity = city || null;
_createLat = lat; _createLon = lon;
document.getElementById('createCityClear').style.display = '';
document.getElementById('createCitySuggestions').style.display = 'none';
document.getElementById('createLocMsg').textContent = '';
}
function clearCreateCity() {
const inp = document.getElementById('createCity');
inp.value = ''; inp.readOnly = false;
_createLat = null; _createLon = null; _createStreet = null; _createCity = null;
document.getElementById('createCityClear').style.display = 'none';
document.getElementById('createLocMsg').textContent = '';
inp.focus();
}
document.addEventListener('click', e => {
if (!e.target.closest('#createStadtRow')) document.getElementById('createCitySuggestions').style.display = 'none';
});
function collectHours() {
const result = [];
for (let d = 1; d <= 7; d++) {
const open = document.getElementById(`open_${d}`)?.value;
const close = document.getElementById(`close_${d}`)?.value;
const closed = document.getElementById(`closed_${d}`)?.checked;
if (open || close || closed) {
result.push({ dayOfWeek: d, openTime: open || null, closeTime: close || null, closed: !!closed });
}
}
return result;
}
async function submitCreate() {
const name = document.getElementById('createName').value.trim();
const desc = document.getElementById('createDesc').value.trim();
const confirmed = document.getElementById('createOwnership').checked;
if (!name) { alert('Bitte gib einen Namen ein.'); return; }
if (!_createLat || !_createLon) {
document.getElementById('createLocMsg').textContent = 'Bitte eine Adresse aus der Vorschlagsliste auswählen oder per GPS ermitteln.';
document.getElementById('createCity').focus();
return;
}
if (!confirmed) { alert('Bitte bestätige, dass du Eigentümer*in der Location bist.'); return; }
const btn = document.getElementById('createSubmitBtn');
btn.disabled = true;
btn.textContent = 'Wird gespeichert…';
try {
const res = await fetch('/locations', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({
name,
description: desc || null,
profilePictureLq: _createLq,
profilePictureHq: _createHq,
lat: _createLat,
lon: _createLon,
street: _createStreet || null,
city: _createCity || null,
ownershipConfirmed: true
})
});
if (!res.ok) throw new Error('Fehler beim Speichern');
const loc = await res.json();
// Öffnungszeiten setzen
const hours = collectHours();
if (hours.length > 0) {
await fetch(`/locations/${loc.locationId}/opening-hours`, {
method: 'PUT',
headers: {'Content-Type':'application/json'},
body: JSON.stringify(hours)
});
}
closeCreateModal();
window.location.href = `/community/location-detail.html?id=${loc.locationId}`;
} catch (err) {
alert('Fehler beim Anlegen: ' + err.message);
} finally {
btn.disabled = false;
btn.textContent = 'Anlegen';
}
}
// ── Init ──────────────────────────────────────────────────────────────────────
fetch('/login/me').then(r => r.ok ? r.json() : null).then(user => {
if (!user) return;
if (user.filterCity) {
savedFilterCity = user.filterCity;
savedFilterLat = user.filterLat;
savedFilterLon = user.filterLon;
savedFilterRadius = user.filterMaxDistKm || 50;
document.getElementById('filterCity').value = savedFilterCity;
document.getElementById('filterCity').readOnly = true;
document.getElementById('filterCityClear').style.display = '';
document.getElementById('filterRadius').value = savedFilterRadius;
document.getElementById('distVal').textContent = savedFilterRadius + ' km';
_inputLat = savedFilterLat;
_inputLon = savedFilterLon;
updateFilterBadge();
runSearch();
} else {
document.getElementById('searchHint').style.display = '';
}
}).catch(() => { document.getElementById('searchHint').style.display = ''; });
loadMine();
</script>
</body>
</html>

View File

@@ -52,6 +52,8 @@
{ href: '/community/nachrichten.html', icon: I('MESSAGES'), label: 'Nachrichten', badgeId: null },
{ href: '/community/benachrichtigungen.html', icon: I('NOTIFICATIONS'), label: 'Benachrichtigungen', badgeId: null },
{ href: '/community/gruppen.html', icon: I('GROUPS'), label: 'Gruppen', badgeId: 'socialGruppenBadge'},
{ href: '/community/locations.html', icon: I('LOCATION') || '📍', label: 'Locations', badgeId: null },
{ href: '/community/events.html', icon: I('EVENT') || '🗓', label: 'Veranstaltungen', badgeId: null },
{ href: '/games/common/einladungen.html', icon: I('INVITATIONS'), label: 'Einladungen', badgeId: null },
];
const socialNav = socialLinks.map(({ href, icon, label, badgeId }) => {