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:
@@ -1,3 +1,5 @@
|
||||
Slomo und Speedup Card
|
||||
|
||||
Sammeln von Erfahrung
|
||||
|
||||
TODO: Im Time Lock, wenn im Spinning Wheel tasks drin sind, dürfen keine sonst keine Tasks gefordert sein und umgekehrt
|
||||
@@ -33,4 +35,8 @@ Ich kann Spieler einladen zu spielen, dann kriegt die Person eine E-Mail und mus
|
||||
|
||||
Die interessantesten wären wohl Würfel und Countdown, da sie mehr Spannung erzeugen ohne den Ablauf zu sehr zu unterbrechen.
|
||||
|
||||
|
||||
|
||||
|
||||
wenn ich dates erfasse kann ich diese auch zu einer Verantstaltung machen,
|
||||
hier kann ich die auswählen, zu denen ich "Ich bin dabei" gedrückt habe, das
|
||||
Date wird dann auf den Standort und Zeitpunkt festgelegt. fragen?
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
bin/main/de/oaa/xxx/location/LocationController$IdsResult.class
Normal file
BIN
bin/main/de/oaa/xxx/location/LocationController$IdsResult.class
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
bin/main/de/oaa/xxx/location/LocationController.class
Normal file
BIN
bin/main/de/oaa/xxx/location/LocationController.class
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
bin/main/de/oaa/xxx/location/LocationEventController.class
Normal file
BIN
bin/main/de/oaa/xxx/location/LocationEventController.class
Normal file
Binary file not shown.
BIN
bin/main/de/oaa/xxx/location/entity/LocationEntity.class
Normal file
BIN
bin/main/de/oaa/xxx/location/entity/LocationEntity.class
Normal file
Binary file not shown.
Binary file not shown.
BIN
bin/main/de/oaa/xxx/location/entity/LocationEventEntity.class
Normal file
BIN
bin/main/de/oaa/xxx/location/entity/LocationEventEntity.class
Normal file
Binary file not shown.
BIN
bin/main/de/oaa/xxx/location/entity/LocationFollowEntity.class
Normal file
BIN
bin/main/de/oaa/xxx/location/entity/LocationFollowEntity.class
Normal file
Binary file not shown.
BIN
bin/main/de/oaa/xxx/location/entity/LocationImageEntity.class
Normal file
BIN
bin/main/de/oaa/xxx/location/entity/LocationImageEntity.class
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
bin/main/de/oaa/xxx/location/repository/LocationRepository.class
Normal file
BIN
bin/main/de/oaa/xxx/location/repository/LocationRepository.class
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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