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

@@ -1,3 +1,5 @@
Slomo und Speedup Card
Sammeln von Erfahrung
TODO: Im Time Lock, wenn im Spinning Wheel tasks drin sind, dürfen keine sonst keine Tasks gefordert sein und umgekehrt
@@ -33,4 +35,8 @@ Ich kann Spieler einladen zu spielen, dann kriegt die Person eine E-Mail und mus
Die interessantesten wären wohl Würfel und Countdown, da sie mehr Spannung erzeugen ohne den Ablauf zu sehr zu unterbrechen.
wenn ich dates erfasse kann ich diese auch zu einer Verantstaltung machen,
hier kann ich die auswählen, zu denen ich "Ich bin dabei" gedrückt habe, das
Date wird dann auf den Standort und Zeitpunkt festgelegt. fragen?

Binary file not shown.

Binary file not shown.

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 }) => {

View File

@@ -1,3 +1,5 @@
Slomo und Speedup Card
Sammeln von Erfahrung
TODO: Im Time Lock, wenn im Spinning Wheel tasks drin sind, dürfen keine sonst keine Tasks gefordert sein und umgekehrt
@@ -33,4 +35,8 @@ Ich kann Spieler einladen zu spielen, dann kriegt die Person eine E-Mail und mus
Die interessantesten wären wohl Würfel und Countdown, da sie mehr Spannung erzeugen ohne den Ablauf zu sehr zu unterbrechen.
wenn ich dates erfasse kann ich diese auch zu einer Verantstaltung machen,
hier kann ich die auswählen, zu denen ich "Ich bin dabei" gedrückt habe, das
Date wird dann auf den Standort und Zeitpunkt festgelegt. fragen?

View File

@@ -71,11 +71,15 @@ public class SecurityConfig {
.requestMatchers("/games/chastity/joinlock.html").authenticated()
.requestMatchers("/community/benachrichtigungen.html").authenticated()
.requestMatchers("/community/abonnements.html").authenticated()
.requestMatchers("/community/locations.html").authenticated()
.requestMatchers("/community/location-detail.html").authenticated()
.requestMatchers("/community/events.html").authenticated()
.requestMatchers("/community/event-detail.html").authenticated()
.requestMatchers("/gruppen/**").authenticated()
.requestMatchers("/feed/**").authenticated()
.requestMatchers("/notifications/**").authenticated()
.requestMatchers("/events/**").authenticated()
.requestMatchers("/*.html").permitAll()
.requestMatchers("/*.html").permitAll()
.requestMatchers("/**/*.html").permitAll()
.requestMatchers("/help/*.html").permitAll()
.requestMatchers("/css/**").permitAll()

View File

@@ -0,0 +1,425 @@
package de.oaa.xxx.location;
import de.oaa.xxx.location.entity.*;
import de.oaa.xxx.location.repository.*;
import de.oaa.xxx.user.UserService;
import jakarta.transaction.Transactional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.security.Principal;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/locations")
public class LocationController {
private static final Logger LOGGER = LoggerFactory.getLogger(LocationController.class);
private static final int MAX_GALLERY_IMAGES = 20;
private static final int MAX_BATCH_SIZE = 50;
private final LocationRepository locationRepo;
private final LocationImageRepository imageRepo;
private final LocationOpeningHoursRepository hoursRepo;
private final LocationEventRepository eventRepo;
private final LocationEventAttendeeRepository attendeeRepo;
private final LocationFollowRepository followRepo;
private final UserService userService;
public LocationController(LocationRepository locationRepo,
LocationImageRepository imageRepo,
LocationOpeningHoursRepository hoursRepo,
LocationEventRepository eventRepo,
LocationEventAttendeeRepository attendeeRepo,
LocationFollowRepository followRepo,
UserService userService) {
this.locationRepo = locationRepo;
this.imageRepo = imageRepo;
this.hoursRepo = hoursRepo;
this.eventRepo = eventRepo;
this.attendeeRepo = attendeeRepo;
this.followRepo = followRepo;
this.userService = userService;
}
// ── DTOs ─────────────────────────────────────────────────────────────────
record IdsResult(List<UUID> ids, int total) {}
record LocationPreviewDto(UUID locationId, String name, String profilePictureLq, double distanzKm) {}
record OpeningHourDto(int dayOfWeek, String openTime, String closeTime, boolean closed) {}
record GalleryImageDto(UUID imageId, String imageData) {}
record LocationDetailDto(
UUID locationId,
UUID ownerId,
String name,
String description,
String profilePictureHq,
String profilePictureLq,
Double lat,
Double lon,
String street,
String city,
boolean ownershipConfirmed,
LocalDateTime createdAt,
List<GalleryImageDto> gallery,
List<OpeningHourDto> openingHours,
boolean following
) {}
record CreateRequest(
String name,
String description,
String profilePictureLq,
String profilePictureHq,
Double lat,
Double lon,
String street,
String city,
boolean ownershipConfirmed
) {}
record UpdateRequest(
String name,
String description,
String profilePictureLq,
String profilePictureHq,
Double lat,
Double lon,
String street,
String city
) {}
record GalleryUploadRequest(String imageData) {}
// ── Suche / IDs ──────────────────────────────────────────────────────────
/**
* Gibt alle Location-IDs zurück, sortiert nach Entfernung vom angegebenen Punkt.
*/
@GetMapping("/ids")
public ResponseEntity<IdsResult> getIds(
@RequestParam double lat,
@RequestParam double lon,
@RequestParam(defaultValue = "50") int maxDistanceKm,
Principal principal) {
userService.requireUser(principal);
List<UUID> sorted = locationRepo.findByLatIsNotNullAndLonIsNotNull().stream()
.filter(l -> haversineKm(lat, lon, l.getLat(), l.getLon()) <= maxDistanceKm)
.sorted(Comparator.comparingDouble(l -> haversineKm(lat, lon, l.getLat(), l.getLon())))
.map(LocationEntity::getLocationId)
.collect(Collectors.toList());
return ResponseEntity.ok(new IdsResult(sorted, sorted.size()));
}
/**
* Batch-Laden von Location-Previews (Name + LQ-Bild + Distanz).
* Benötigt lat/lon für die Distanzberechnung.
*/
@PostMapping("/batch")
public ResponseEntity<List<LocationPreviewDto>> getBatch(
@RequestBody BatchRequest request,
Principal principal) {
userService.requireUser(principal);
if (request.ids() == null || request.ids().isEmpty()) return ResponseEntity.ok(List.of());
List<UUID> ids = request.ids().stream().filter(Objects::nonNull).limit(MAX_BATCH_SIZE).toList();
Map<UUID, LocationEntity> byId = locationRepo.findAllById(ids).stream()
.collect(Collectors.toMap(LocationEntity::getLocationId, l -> l));
double refLat = request.lat() != null ? request.lat() : 0;
double refLon = request.lon() != null ? request.lon() : 0;
List<LocationPreviewDto> result = ids.stream()
.map(byId::get)
.filter(Objects::nonNull)
.map(l -> new LocationPreviewDto(
l.getLocationId(),
l.getName(),
l.getProfilePictureLq(),
l.getLat() != null && l.getLon() != null
? Math.round(haversineKm(refLat, refLon, l.getLat(), l.getLon()) * 10.0) / 10.0
: -1))
.toList();
return ResponseEntity.ok(result);
}
record BatchRequest(List<UUID> ids, Double lat, Double lon) {}
/**
* Meine eigenen Locations (IDs, neueste zuerst).
*/
@GetMapping("/mine")
public ResponseEntity<IdsResult> getMine(Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
List<UUID> ids = locationRepo.findByOwnerIdOrderByCreatedAtDesc(myId).stream()
.map(LocationEntity::getLocationId)
.toList();
return ResponseEntity.ok(new IdsResult(ids, ids.size()));
}
// ── Detail ───────────────────────────────────────────────────────────────
@GetMapping("/{locationId}")
public ResponseEntity<LocationDetailDto> getDetail(
@PathVariable UUID locationId,
Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
return locationRepo.findById(locationId)
.map(l -> ResponseEntity.ok(toDetail(l, myId)))
.orElse(ResponseEntity.notFound().build());
}
// ── CRUD ─────────────────────────────────────────────────────────────────
@PostMapping
public ResponseEntity<LocationDetailDto> create(
@RequestBody CreateRequest req,
Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
if (req.name() == null || req.name().isBlank()) return ResponseEntity.badRequest().build();
LocationEntity loc = new LocationEntity();
loc.setLocationId(UUID.randomUUID());
loc.setOwnerId(myId);
loc.setName(req.name().trim());
loc.setDescription(req.description() != null ? req.description().trim() : null);
loc.setProfilePictureLq(req.profilePictureLq());
loc.setProfilePictureHq(req.profilePictureHq());
loc.setLat(req.lat());
loc.setLon(req.lon());
loc.setStreet(req.street());
loc.setCity(req.city());
loc.setOwnershipConfirmed(req.ownershipConfirmed());
loc.setCreatedAt(LocalDateTime.now());
locationRepo.save(loc);
LOGGER.info("User {} hat Location {} angelegt", myId, loc.getLocationId());
return ResponseEntity.status(201).body(toDetail(loc, myId));
}
@PutMapping("/{locationId}")
public ResponseEntity<LocationDetailDto> update(
@PathVariable UUID locationId,
@RequestBody UpdateRequest req,
Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
var opt = locationRepo.findById(locationId);
if (opt.isEmpty()) return ResponseEntity.notFound().build();
LocationEntity loc = opt.get();
if (!loc.getOwnerId().equals(myId)) return ResponseEntity.status(403).build();
if (req.name() != null && !req.name().isBlank()) loc.setName(req.name().trim());
if (req.description() != null) loc.setDescription(req.description().trim());
if (req.profilePictureLq() != null) loc.setProfilePictureLq(req.profilePictureLq());
if (req.profilePictureHq() != null) loc.setProfilePictureHq(req.profilePictureHq());
if (req.lat() != null) loc.setLat(req.lat());
if (req.lon() != null) loc.setLon(req.lon());
if (req.street() != null) loc.setStreet(req.street());
if (req.city() != null) loc.setCity(req.city());
locationRepo.save(loc);
return ResponseEntity.ok(toDetail(loc, myId));
}
@Transactional
@DeleteMapping("/{locationId}")
public ResponseEntity<Void> delete(
@PathVariable UUID locationId,
Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
var opt = locationRepo.findById(locationId);
if (opt.isEmpty()) return ResponseEntity.notFound().build();
if (!opt.get().getOwnerId().equals(myId)) return ResponseEntity.status(403).build();
// Kaskade: Events + Attendees + Bilder + Öffnungszeiten + Follows löschen
eventRepo.findByLocationIdOrderByStartAtAsc(locationId).forEach(e -> {
attendeeRepo.deleteByEventId(e.getEventId());
});
eventRepo.deleteByLocationId(locationId);
imageRepo.deleteByLocationId(locationId);
hoursRepo.deleteByLocationId(locationId);
followRepo.deleteByLocationId(locationId);
locationRepo.deleteById(locationId);
LOGGER.info("User {} hat Location {} gelöscht", myId, locationId);
return ResponseEntity.noContent().build();
}
// ── Galerie ───────────────────────────────────────────────────────────────
@PostMapping("/{locationId}/gallery")
public ResponseEntity<GalleryImageDto> uploadGalleryImage(
@PathVariable UUID locationId,
@RequestBody GalleryUploadRequest req,
Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
var opt = locationRepo.findById(locationId);
if (opt.isEmpty()) return ResponseEntity.notFound().build();
if (!opt.get().getOwnerId().equals(myId)) return ResponseEntity.status(403).build();
if (req.imageData() == null || req.imageData().isBlank()) return ResponseEntity.badRequest().build();
if (imageRepo.countByLocationId(locationId) >= MAX_GALLERY_IMAGES) return ResponseEntity.status(422).build();
LocationImageEntity img = new LocationImageEntity();
img.setImageId(UUID.randomUUID());
img.setLocationId(locationId);
img.setImageData(req.imageData());
img.setUploadedAt(LocalDateTime.now());
imageRepo.save(img);
return ResponseEntity.status(201).body(new GalleryImageDto(img.getImageId(), img.getImageData()));
}
@Transactional
@DeleteMapping("/{locationId}/gallery/{imageId}")
public ResponseEntity<Void> deleteGalleryImage(
@PathVariable UUID locationId,
@PathVariable UUID imageId,
Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
var locOpt = locationRepo.findById(locationId);
if (locOpt.isEmpty()) return ResponseEntity.notFound().build();
if (!locOpt.get().getOwnerId().equals(myId)) return ResponseEntity.status(403).build();
var imgOpt = imageRepo.findById(imageId);
if (imgOpt.isEmpty()) return ResponseEntity.notFound().build();
if (!imgOpt.get().getLocationId().equals(locationId)) return ResponseEntity.status(400).build();
imageRepo.delete(imgOpt.get());
return ResponseEntity.noContent().build();
}
// ── Öffnungszeiten ────────────────────────────────────────────────────────
@GetMapping("/{locationId}/opening-hours")
public ResponseEntity<List<OpeningHourDto>> getOpeningHours(
@PathVariable UUID locationId,
Principal principal) {
userService.requireUser(principal);
if (!locationRepo.existsById(locationId)) return ResponseEntity.notFound().build();
List<OpeningHourDto> dtos = hoursRepo.findByLocationIdOrderByDayOfWeek(locationId).stream()
.map(h -> new OpeningHourDto(h.getDayOfWeek(), h.getOpenTime(), h.getCloseTime(), h.isClosed()))
.toList();
return ResponseEntity.ok(dtos);
}
/**
* Setzt die Öffnungszeiten einer Location vollständig neu (kompletter Ersatz).
*/
@Transactional
@PutMapping("/{locationId}/opening-hours")
public ResponseEntity<List<OpeningHourDto>> setOpeningHours(
@PathVariable UUID locationId,
@RequestBody List<OpeningHourDto> hours,
Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
var opt = locationRepo.findById(locationId);
if (opt.isEmpty()) return ResponseEntity.notFound().build();
if (!opt.get().getOwnerId().equals(myId)) return ResponseEntity.status(403).build();
hoursRepo.deleteByLocationId(locationId);
List<LocationOpeningHoursEntity> saved = new ArrayList<>();
for (OpeningHourDto dto : hours) {
if (dto.dayOfWeek() < 1 || dto.dayOfWeek() > 7) continue;
LocationOpeningHoursEntity e = new LocationOpeningHoursEntity();
e.setHoursId(UUID.randomUUID());
e.setLocationId(locationId);
e.setDayOfWeek(dto.dayOfWeek());
e.setOpenTime(dto.openTime());
e.setCloseTime(dto.closeTime());
e.setClosed(dto.closed());
saved.add(hoursRepo.save(e));
}
return ResponseEntity.ok(saved.stream()
.map(h -> new OpeningHourDto(h.getDayOfWeek(), h.getOpenTime(), h.getCloseTime(), h.isClosed()))
.toList());
}
// ── Follow ────────────────────────────────────────────────────────────────
@PostMapping("/{locationId}/follow")
public ResponseEntity<Map<String, Object>> toggleFollow(
@PathVariable UUID locationId,
Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
if (!locationRepo.existsById(locationId)) return ResponseEntity.notFound().build();
var existing = followRepo.findByUserIdAndLocationId(myId, locationId);
boolean following;
if (existing.isPresent()) {
followRepo.delete(existing.get());
following = false;
} else {
var f = new de.oaa.xxx.location.entity.LocationFollowEntity();
f.setFollowId(UUID.randomUUID());
f.setLocationId(locationId);
f.setUserId(myId);
f.setFollowedAt(LocalDateTime.now());
followRepo.save(f);
following = true;
}
return ResponseEntity.ok(Map.of("following", following));
}
@GetMapping("/followed")
public ResponseEntity<IdsResult> getFollowed(Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
List<UUID> ids = followRepo.findByUserId(myId).stream()
.map(de.oaa.xxx.location.entity.LocationFollowEntity::getLocationId)
.toList();
return ResponseEntity.ok(new IdsResult(ids, ids.size()));
}
// ── Helpers ───────────────────────────────────────────────────────────────
private LocationDetailDto toDetail(LocationEntity l, UUID myId) {
List<GalleryImageDto> gallery = imageRepo.findByLocationIdOrderByUploadedAtAsc(l.getLocationId()).stream()
.map(i -> new GalleryImageDto(i.getImageId(), i.getImageData()))
.toList();
List<OpeningHourDto> hours = hoursRepo.findByLocationIdOrderByDayOfWeek(l.getLocationId()).stream()
.map(h -> new OpeningHourDto(h.getDayOfWeek(), h.getOpenTime(), h.getCloseTime(), h.isClosed()))
.toList();
boolean following = followRepo.findByUserIdAndLocationId(myId, l.getLocationId()).isPresent();
return new LocationDetailDto(
l.getLocationId(), l.getOwnerId(), l.getName(), l.getDescription(),
l.getProfilePictureHq(), l.getProfilePictureLq(),
l.getLat(), l.getLon(), l.getStreet(), l.getCity(),
l.isOwnershipConfirmed(), l.getCreatedAt(),
gallery, hours, following);
}
static double haversineKm(double lat1, double lon1, double lat2, double lon2) {
final double R = 6371.0;
double dLat = Math.toRadians(lat2 - lat1);
double dLon = Math.toRadians(lon2 - lon1);
double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
+ Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
* Math.sin(dLon / 2) * Math.sin(dLon / 2);
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
}

View File

@@ -0,0 +1,384 @@
package de.oaa.xxx.location;
import java.security.Principal;
import java.time.LocalDateTime;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import de.oaa.xxx.location.entity.LocationEventAttendeeEntity;
import de.oaa.xxx.location.entity.LocationEventEntity;
import de.oaa.xxx.location.entity.LocationFollowEntity;
import de.oaa.xxx.location.repository.LocationEventAttendeeRepository;
import de.oaa.xxx.location.repository.LocationEventRepository;
import de.oaa.xxx.location.repository.LocationFollowRepository;
import de.oaa.xxx.location.repository.LocationRepository;
import de.oaa.xxx.user.UserEntity;
import de.oaa.xxx.user.UserRepository;
import de.oaa.xxx.user.UserService;
import jakarta.transaction.Transactional;
@RestController
public class LocationEventController {
private static final Logger LOGGER = LoggerFactory.getLogger(LocationEventController.class);
private static final int MAX_BATCH_SIZE = 50;
private final LocationRepository locationRepo;
private final LocationEventRepository eventRepo;
private final LocationEventAttendeeRepository attendeeRepo;
private final LocationFollowRepository followRepo;
private final UserRepository userRepo;
private final UserService userService;
public LocationEventController(LocationRepository locationRepo,
LocationEventRepository eventRepo,
LocationEventAttendeeRepository attendeeRepo,
LocationFollowRepository followRepo,
UserRepository userRepo,
UserService userService) {
this.locationRepo = locationRepo;
this.eventRepo = eventRepo;
this.attendeeRepo = attendeeRepo;
this.followRepo = followRepo;
this.userRepo = userRepo;
this.userService = userService;
}
// ── DTOs ─────────────────────────────────────────────────────────────────
record IdsResult(List<UUID> ids, int total) {}
record EventPreviewDto(
UUID eventId,
UUID locationId,
String locationName,
String title,
String imageData,
LocalDateTime startAt,
double distanzKm,
long attendeeCount,
boolean attendingMe
) {}
record AttendeeDto(UUID userId, String name, String profilePictureLq, String geschlecht) {}
record EventDetailDto(
UUID eventId,
UUID locationId,
String locationName,
String title,
String description,
String imageData,
LocalDateTime startAt,
LocalDateTime createdAt,
boolean attendingMe,
List<AttendeeDto> attendees
) {}
record CreateEventRequest(String title, String description, String imageData, LocalDateTime startAt) {}
record UpdateEventRequest(String title, String description, String imageData, LocalDateTime startAt) {}
record BatchRequest(List<UUID> ids, Double lat, Double lon) {}
// ── Events einer Location ─────────────────────────────────────────────────
@GetMapping("/locations/{locationId}/events")
public ResponseEntity<List<EventPreviewDto>> getEventsForLocation(
@PathVariable UUID locationId,
Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
if (!locationRepo.existsById(locationId)) return ResponseEntity.notFound().build();
var location = locationRepo.findById(locationId).orElseThrow();
double refLat = location.getLat() != null ? location.getLat() : 0;
double refLon = location.getLon() != null ? location.getLon() : 0;
List<EventPreviewDto> dtos = eventRepo.findByLocationIdOrderByStartAtAsc(locationId).stream()
.map(e -> toPreview(e, location.getName(), refLat, refLon, myId))
.toList();
return ResponseEntity.ok(dtos);
}
@PostMapping("/locations/{locationId}/events")
public ResponseEntity<EventDetailDto> createEvent(
@PathVariable UUID locationId,
@RequestBody CreateEventRequest req,
Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
var locOpt = locationRepo.findById(locationId);
if (locOpt.isEmpty()) return ResponseEntity.notFound().build();
if (!locOpt.get().getOwnerId().equals(myId)) return ResponseEntity.status(403).build();
if (req.title() == null || req.title().isBlank()) return ResponseEntity.badRequest().build();
if (req.startAt() == null) return ResponseEntity.badRequest().build();
LocationEventEntity event = new LocationEventEntity();
event.setEventId(UUID.randomUUID());
event.setLocationId(locationId);
event.setTitle(req.title().trim());
event.setDescription(req.description() != null ? req.description().trim() : null);
event.setImageData(req.imageData());
event.setStartAt(req.startAt());
event.setCreatedAt(LocalDateTime.now());
eventRepo.save(event);
LOGGER.info("Location {} hat Event {} angelegt", locationId, event.getEventId());
return ResponseEntity.status(201).body(toDetail(event, locOpt.get().getName(), myId));
}
@PutMapping("/locations/{locationId}/events/{eventId}")
public ResponseEntity<EventDetailDto> updateEvent(
@PathVariable UUID locationId,
@PathVariable UUID eventId,
@RequestBody UpdateEventRequest req,
Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
var locOpt = locationRepo.findById(locationId);
if (locOpt.isEmpty()) return ResponseEntity.notFound().build();
if (!locOpt.get().getOwnerId().equals(myId)) return ResponseEntity.status(403).build();
var evtOpt = eventRepo.findById(eventId);
if (evtOpt.isEmpty()) return ResponseEntity.notFound().build();
if (!evtOpt.get().getLocationId().equals(locationId)) return ResponseEntity.status(400).build();
LocationEventEntity event = evtOpt.get();
if (req.title() != null && !req.title().isBlank()) event.setTitle(req.title().trim());
if (req.description() != null) event.setDescription(req.description().trim());
if (req.imageData() != null) event.setImageData(req.imageData());
if (req.startAt() != null) event.setStartAt(req.startAt());
eventRepo.save(event);
return ResponseEntity.ok(toDetail(event, locOpt.get().getName(), myId));
}
@Transactional
@DeleteMapping("/locations/{locationId}/events/{eventId}")
public ResponseEntity<Void> deleteEvent(
@PathVariable UUID locationId,
@PathVariable UUID eventId,
Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
var locOpt = locationRepo.findById(locationId);
if (locOpt.isEmpty()) return ResponseEntity.notFound().build();
if (!locOpt.get().getOwnerId().equals(myId)) return ResponseEntity.status(403).build();
var evtOpt = eventRepo.findById(eventId);
if (evtOpt.isEmpty()) return ResponseEntity.notFound().build();
if (!evtOpt.get().getLocationId().equals(locationId)) return ResponseEntity.status(400).build();
attendeeRepo.deleteByEventId(eventId);
eventRepo.delete(evtOpt.get());
return ResponseEntity.noContent().build();
}
// ── Event-Detail und Teilnahme ────────────────────────────────────────────
@GetMapping("/location-events/{eventId}")
public ResponseEntity<EventDetailDto> getEvent(
@PathVariable UUID eventId,
Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
var evtOpt = eventRepo.findById(eventId);
if (evtOpt.isEmpty()) return ResponseEntity.notFound().build();
String locationName = locationRepo.findById(evtOpt.get().getLocationId())
.map(l -> l.getName()).orElse("");
return ResponseEntity.ok(toDetail(evtOpt.get(), locationName, myId));
}
@PostMapping("/location-events/{eventId}/attend")
public ResponseEntity<Map<String, Object>> toggleAttend(
@PathVariable UUID eventId,
Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
if (!eventRepo.existsById(eventId)) return ResponseEntity.notFound().build();
var existing = attendeeRepo.findByEventIdAndUserId(eventId, myId);
boolean attending;
if (existing.isPresent()) {
attendeeRepo.delete(existing.get());
attending = false;
} else {
LocationEventAttendeeEntity a = new LocationEventAttendeeEntity();
a.setAttendeeId(UUID.randomUUID());
a.setEventId(eventId);
a.setUserId(myId);
a.setRegisteredAt(LocalDateTime.now());
attendeeRepo.save(a);
attending = true;
}
long count = attendeeRepo.countByEventId(eventId);
return ResponseEntity.ok(Map.of("attending", attending, "attendeeCount", count));
}
// ── Event-Suche (IDs + Batch) ─────────────────────────────────────────────
/**
* Liefert Event-IDs sortiert nach Datum (nächste zuerst).
* Immer enthalten: Events von abonnierten Locations.
* Optional: Events im Umkreis von lat/lon (wenn angegeben).
*/
@GetMapping("/location-events/ids")
public ResponseEntity<IdsResult> searchIds(
@RequestParam(required = false) Double lat,
@RequestParam(required = false) Double lon,
@RequestParam(defaultValue = "50") int maxDistanceKm,
@RequestParam(required = false) String from,
@RequestParam(required = false) String to,
Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
LocalDateTime fromDt = from != null ? LocalDateTime.parse(from) : LocalDateTime.now();
LocalDateTime toDt = to != null ? LocalDateTime.parse(to) : fromDt.plusMonths(3);
// Abonnierte Locations deren Events werden immer eingeschlossen
Set<UUID> followedLocationIds = followRepo.findByUserId(myId).stream()
.map(LocationFollowEntity::getLocationId)
.collect(Collectors.toSet());
Map<UUID, double[]> locationCoords = new HashMap<>();
List<UUID> sorted = eventRepo.findInTimeRange(fromDt, toDt).stream()
.filter(e -> {
// Immer einschließen wenn Location abonniert
if (followedLocationIds.contains(e.getLocationId())) return true;
// Ohne Koordinaten kein Umkreis-Filter
if (lat == null || lon == null) return false;
double[] coords = locationCoords.computeIfAbsent(e.getLocationId(), locId ->
locationRepo.findById(locId)
.filter(l -> l.getLat() != null && l.getLon() != null)
.map(l -> new double[]{l.getLat(), l.getLon()})
.orElse(null));
if (coords == null) return false;
return LocationController.haversineKm(lat, lon, coords[0], coords[1]) <= maxDistanceKm;
})
.sorted(Comparator.comparing(LocationEventEntity::getStartAt))
.map(LocationEventEntity::getEventId)
.collect(Collectors.toList());
return ResponseEntity.ok(new IdsResult(sorted, sorted.size()));
}
/**
* Batch-Laden von Event-Previews.
*/
@PostMapping("/location-events/batch")
public ResponseEntity<List<EventPreviewDto>> getBatch(
@RequestBody BatchRequest request,
Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
if (request.ids() == null || request.ids().isEmpty()) return ResponseEntity.ok(List.of());
List<UUID> ids = request.ids().stream().filter(Objects::nonNull).limit(MAX_BATCH_SIZE).toList();
Map<UUID, LocationEventEntity> byId = eventRepo.findAllById(ids).stream()
.collect(Collectors.toMap(LocationEventEntity::getEventId, Function.identity()));
double refLat = request.lat() != null ? request.lat() : 0;
double refLon = request.lon() != null ? request.lon() : 0;
// Locations für Namens-Lookup und Distanzberechnung
Set<UUID> locationIds = byId.values().stream()
.map(LocationEventEntity::getLocationId).collect(Collectors.toSet());
Map<UUID, de.oaa.xxx.location.entity.LocationEntity> locationById =
locationRepo.findAllById(locationIds).stream()
.collect(Collectors.toMap(
de.oaa.xxx.location.entity.LocationEntity::getLocationId, Function.identity()));
List<EventPreviewDto> result = ids.stream()
.map(byId::get)
.filter(Objects::nonNull)
.map(e -> {
var loc = locationById.get(e.getLocationId());
String locName = loc != null ? loc.getName() : "";
double dist = (loc != null && loc.getLat() != null && loc.getLon() != null)
? Math.round(LocationController.haversineKm(refLat, refLon, loc.getLat(), loc.getLon()) * 10.0) / 10.0
: -1;
return toPreview(e, locName, refLat, refLon, myId);
})
.toList();
return ResponseEntity.ok(result);
}
// ── Helpers ───────────────────────────────────────────────────────────────
private EventPreviewDto toPreview(LocationEventEntity e, String locationName,
double refLat, double refLon, UUID myId) {
var loc = locationRepo.findById(e.getLocationId()).orElse(null);
double dist = (loc != null && loc.getLat() != null && loc.getLon() != null)
? Math.round(LocationController.haversineKm(refLat, refLon, loc.getLat(), loc.getLon()) * 10.0) / 10.0
: -1;
long count = attendeeRepo.countByEventId(e.getEventId());
boolean mine = attendeeRepo.findByEventIdAndUserId(e.getEventId(), myId).isPresent();
return new EventPreviewDto(e.getEventId(), e.getLocationId(), locationName,
e.getTitle(), e.getImageData(), e.getStartAt(), dist, count, mine);
}
private EventDetailDto toDetail(LocationEventEntity e, String locationName, UUID myId) {
List<LocationEventAttendeeEntity> attendeeEntities =
attendeeRepo.findByEventIdOrderByRegisteredAtAsc(e.getEventId());
Set<UUID> attendeeUserIds = attendeeEntities.stream()
.map(LocationEventAttendeeEntity::getUserId).collect(Collectors.toSet());
Map<UUID, UserEntity> userById = attendeeUserIds.isEmpty()
? Map.of()
: userRepo.findAllById(attendeeUserIds).stream()
.collect(Collectors.toMap(UserEntity::getUserId, Function.identity()));
// Sortierung: Alphabetisch nach Geschlecht-Name, dann nach Registrierungszeit
List<AttendeeDto> attendees = attendeeEntities.stream()
.map(a -> {
UserEntity u = userById.get(a.getUserId());
if (u == null) return null;
String geschlecht = u.getGeschlecht() != null ? u.getGeschlecht().name() : "UNBEKANNT";
return new AttendeeDto(u.getUserId(), u.getName(), u.getProfilePicture(), geschlecht);
})
.filter(Objects::nonNull)
.sorted(Comparator
.comparing((AttendeeDto a) -> {
// Sortierreihenfolge: WEIBLICH → MAENNLICH → DIVERS → rest
return switch (a.geschlecht()) {
case "WEIBLICH" -> 0;
case "MAENNLICH" -> 1;
case "DIVERS" -> 2;
default -> 3;
};
})
.thenComparing(AttendeeDto::name))
.toList();
boolean attendingMe = attendeeRepo.findByEventIdAndUserId(e.getEventId(), myId).isPresent();
return new EventDetailDto(
e.getEventId(), e.getLocationId(), locationName,
e.getTitle(), e.getDescription(), e.getImageData(),
e.getStartAt(), e.getCreatedAt(),
attendingMe, attendees);
}
}

View File

@@ -0,0 +1,55 @@
package de.oaa.xxx.location.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.UUID;
@Getter
@Setter
@Entity
@Table(name = "location")
public class LocationEntity {
@Id
@Column
private UUID locationId;
@Column(nullable = false)
private UUID ownerId;
@Column(nullable = false, length = 200)
private String name;
@Column(columnDefinition = "TEXT")
private String description;
/** LQ-Profilbild (klein, für Listenansicht) base64 */
@Column(columnDefinition = "TEXT")
private String profilePictureLq;
/** HQ-Profilbild (max 1024×1024) base64 */
@Column(columnDefinition = "MEDIUMTEXT")
private String profilePictureHq;
@Column
private Double lat;
@Column
private Double lon;
@Column(length = 200)
private String street;
@Column(length = 200)
private String city;
/** Eigentümerschaft wurde vom Anleger bestätigt */
@Column(nullable = false, columnDefinition = "TINYINT(1) DEFAULT 0")
private boolean ownershipConfirmed = false;
@Column(nullable = false)
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,29 @@
package de.oaa.xxx.location.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.UUID;
@Getter
@Setter
@Entity
@Table(name = "location_event_attendee",
uniqueConstraints = @UniqueConstraint(columnNames = {"event_id", "user_id"}))
public class LocationEventAttendeeEntity {
@Id
@Column
private UUID attendeeId;
@Column(nullable = false, name = "event_id")
private UUID eventId;
@Column(nullable = false, name = "user_id")
private UUID userId;
@Column(nullable = false)
private LocalDateTime registeredAt;
}

View File

@@ -0,0 +1,38 @@
package de.oaa.xxx.location.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.UUID;
@Getter
@Setter
@Entity
@Table(name = "location_event")
public class LocationEventEntity {
@Id
@Column
private UUID eventId;
@Column(nullable = false)
private UUID locationId;
@Column(nullable = false, length = 200)
private String title;
@Column(columnDefinition = "TEXT")
private String description;
/** base64-Profilbild der Veranstaltung, max 1024px */
@Column(columnDefinition = "MEDIUMTEXT")
private String imageData;
@Column(nullable = false)
private LocalDateTime startAt;
@Column(nullable = false)
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,31 @@
package de.oaa.xxx.location.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.UUID;
@Getter
@Setter
@Entity
@Table(name = "location_follow")
public class LocationFollowEntity {
@Id
@Column
private UUID followId;
@Column(nullable = false)
private UUID locationId;
@Column(nullable = false)
private UUID userId;
@Column(nullable = false)
private LocalDateTime followedAt;
}

View File

@@ -0,0 +1,29 @@
package de.oaa.xxx.location.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.UUID;
@Getter
@Setter
@Entity
@Table(name = "location_image")
public class LocationImageEntity {
@Id
@Column
private UUID imageId;
@Column(nullable = false)
private UUID locationId;
/** base64-kodiertes Bild, max 1024px */
@Column(nullable = false, columnDefinition = "MEDIUMTEXT")
private String imageData;
@Column(nullable = false)
private LocalDateTime uploadedAt;
}

View File

@@ -0,0 +1,40 @@
package de.oaa.xxx.location.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.util.UUID;
@Getter
@Setter
@Entity
@Table(name = "location_opening_hours",
uniqueConstraints = @UniqueConstraint(columnNames = {"location_id", "day_of_week"}))
public class LocationOpeningHoursEntity {
@Id
@Column
private UUID hoursId;
@Column(nullable = false, name = "location_id")
private UUID locationId;
/**
* Wochentag: 1 = Montag, 2 = Dienstag, ..., 7 = Sonntag
*/
@Column(nullable = false, name = "day_of_week")
private int dayOfWeek;
/** Öffnungszeit als "HH:mm"-String, null = kein Eintrag */
@Column(length = 5)
private String openTime;
/** Schließzeit als "HH:mm"-String, null = kein Eintrag */
@Column(length = 5)
private String closeTime;
/** true = an diesem Tag geschlossen */
@Column(nullable = false, columnDefinition = "TINYINT(1) DEFAULT 0")
private boolean closed = false;
}

View File

@@ -0,0 +1,21 @@
package de.oaa.xxx.location.repository;
import de.oaa.xxx.location.entity.LocationEventAttendeeEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface LocationEventAttendeeRepository extends JpaRepository<LocationEventAttendeeEntity, UUID> {
List<LocationEventAttendeeEntity> findByEventIdOrderByRegisteredAtAsc(UUID eventId);
Optional<LocationEventAttendeeEntity> findByEventIdAndUserId(UUID eventId, UUID userId);
long countByEventId(UUID eventId);
void deleteByEventId(UUID eventId);
void deleteByUserId(UUID userId);
}

View File

@@ -0,0 +1,24 @@
package de.oaa.xxx.location.repository;
import de.oaa.xxx.location.entity.LocationEventEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
public interface LocationEventRepository extends JpaRepository<LocationEventEntity, UUID> {
List<LocationEventEntity> findByLocationIdOrderByStartAtAsc(UUID locationId);
/** Alle zukünftigen Events mit Koordinaten ihrer Location (für Umkreis-Suche) */
@Query("""
SELECT e FROM LocationEventEntity e
WHERE e.startAt >= :from
AND e.startAt <= :to
""")
List<LocationEventEntity> findInTimeRange(LocalDateTime from, LocalDateTime to);
void deleteByLocationId(UUID locationId);
}

View File

@@ -0,0 +1,14 @@
package de.oaa.xxx.location.repository;
import de.oaa.xxx.location.entity.LocationFollowEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface LocationFollowRepository extends JpaRepository<LocationFollowEntity, UUID> {
Optional<LocationFollowEntity> findByUserIdAndLocationId(UUID userId, UUID locationId);
List<LocationFollowEntity> findByUserId(UUID userId);
void deleteByLocationId(UUID locationId);
}

View File

@@ -0,0 +1,16 @@
package de.oaa.xxx.location.repository;
import de.oaa.xxx.location.entity.LocationImageEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface LocationImageRepository extends JpaRepository<LocationImageEntity, UUID> {
List<LocationImageEntity> findByLocationIdOrderByUploadedAtAsc(UUID locationId);
long countByLocationId(UUID locationId);
void deleteByLocationId(UUID locationId);
}

View File

@@ -0,0 +1,14 @@
package de.oaa.xxx.location.repository;
import de.oaa.xxx.location.entity.LocationOpeningHoursEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface LocationOpeningHoursRepository extends JpaRepository<LocationOpeningHoursEntity, UUID> {
List<LocationOpeningHoursEntity> findByLocationIdOrderByDayOfWeek(UUID locationId);
void deleteByLocationId(UUID locationId);
}

View File

@@ -0,0 +1,15 @@
package de.oaa.xxx.location.repository;
import de.oaa.xxx.location.entity.LocationEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface LocationRepository extends JpaRepository<LocationEntity, UUID> {
List<LocationEntity> findByOwnerIdOrderByCreatedAtDesc(UUID ownerId);
/** Alle Locations mit gesetzten Koordinaten (für Umkreissuche) */
List<LocationEntity> findByLatIsNotNullAndLonIsNotNull();
}

View File

@@ -33,6 +33,10 @@ public class User {
private Integer datingMaxDistanzKm;
private Integer datingMinAlter;
private Integer datingMaxAlter;
private String filterCity;
private Double filterLat;
private Double filterLon;
private Integer filterMaxDistKm;
public Integer getAlter() {
return geburtsdatum != null ? Period.between(geburtsdatum, LocalDate.now()).getYears() : null;

View File

@@ -500,4 +500,17 @@ public class UserController {
return ResponseEntity.internalServerError().build();
}
}
record LocationFilterRequest(String filterCity, Double filterLat, Double filterLon, Integer filterMaxDistKm) {}
@PutMapping("/me/location-filter")
public ResponseEntity<Void> updateLocationFilter(@RequestBody LocationFilterRequest request, Principal principal) {
var user = userService.requireUser(principal);
if (request.filterCity() != null) user.setFilterCity(request.filterCity());
if (request.filterLat() != null) user.setFilterLat(request.filterLat());
if (request.filterLon() != null) user.setFilterLon(request.filterLon());
if (request.filterMaxDistKm() != null) user.setFilterMaxDistKm(Math.max(1, Math.min(500, request.filterMaxDistKm())));
userRepository.save(user);
return ResponseEntity.ok().build();
}
}

View File

@@ -134,6 +134,19 @@ public class UserEntity {
@Column
private Integer datingMaxAlter;
// ── Locations/Events-Filter ──
@Column(length = 200)
private String filterCity;
@Column
private Double filterLat;
@Column
private Double filterLon;
@Column
private Integer filterMaxDistKm;
public Integer getAlter() {
return geburtsdatum != null ? Period.between(geburtsdatum, LocalDate.now()).getYears() : null;
}
@@ -167,6 +180,10 @@ public class UserEntity {
user.setDatingMaxDistanzKm(datingMaxDistanzKm);
user.setDatingMinAlter(datingMinAlter);
user.setDatingMaxAlter(datingMaxAlter);
user.setFilterCity(filterCity);
user.setFilterLat(filterLat);
user.setFilterLon(filterLon);
user.setFilterMaxDistKm(filterMaxDistKm);
return user;
}
}

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 }) => {