An den Verantaltungen und Locations gearbeitet
Some checks failed
Host-Based Deploy (Java 21 Fix) / build-and-run (push) Has been cancelled
Some checks failed
Host-Based Deploy (Java 21 Fix) / build-and-run (push) Has been cancelled
This commit is contained in:
177
bin/main/static/community/event-detail.html
Normal file
177
bin/main/static/community/event-detail.html
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
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>
|
||||
582
bin/main/static/community/events.html
Normal file
582
bin/main/static/community/events.html
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
function formatDate(dt) {
|
||||
if (!dt) return '';
|
||||
const d = new Date(dt);
|
||||
return d.toLocaleDateString('de-DE', { weekday:'short', day:'2-digit', month:'2-digit', year:'numeric' })
|
||||
+ ', ' + d.toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' }) + ' Uhr';
|
||||
}
|
||||
|
||||
// ── IntersectionObserver für Scroll-Paging ────────────────────────────────────
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
if (entries[0].isIntersecting) loadNextBatch();
|
||||
}, { rootMargin: '300px' });
|
||||
observer.observe(document.getElementById('sentinel'));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
628
bin/main/static/community/location-detail.html
Normal file
628
bin/main/static/community/location-detail.html
Normal 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 & 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
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>
|
||||
647
bin/main/static/community/locations.html
Normal file
647
bin/main/static/community/locations.html
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// ── 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>
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user