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

This commit is contained in:
2026-04-06 22:48:34 +02:00
parent 0f9f109067
commit 5ffb99c9b5
81 changed files with 2817 additions and 352 deletions

View File

@@ -12,7 +12,7 @@
.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 { 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: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; }
@@ -26,7 +26,7 @@
.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-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(180px,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; }
@@ -59,19 +59,72 @@
/* 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 img { max-width:85vw; max-height:90vh; 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; }
.lb-nav { position:absolute; top:50%; transform:translateY(-50%); background:rgba(255,255,255,.15); border:none; color:#fff; font-size:2.5rem; line-height:1; cursor:pointer; padding:0.3rem 0.8rem; border-radius:8px; transition:background 0.15s; user-select:none; }
.lb-nav:hover { background:rgba(255,255,255,.3); }
.lb-nav:disabled { opacity:0.2; cursor:default; }
.lb-prev { left:1rem; }
.lb-next { right:1rem; }
.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; }
/* Tabs */
.tab-bar { display:flex; gap:0; border-bottom:2px solid var(--color-secondary); margin-bottom:1.25rem; flex-wrap:wrap; }
.tab-btn { background:none; border:none; border-bottom:2px solid transparent; color:var(--color-muted); cursor:pointer; font-size:0.88rem; font-weight:600; padding:0.65rem 1rem; margin-bottom:-2px; border-radius:0; width:auto; margin-top:0; transition:color 0.15s, border-color 0.15s; white-space:nowrap; }
.tab-btn:hover { color:var(--color-text); background:none; }
.tab-btn.active { color:var(--color-text); border-bottom-color:var(--color-primary); }
.tab-panel { display:none; }
.tab-panel.active { display:block; }
/* Posteingang */
.inbox-list { display:flex; flex-direction:column; gap:0.5rem; }
.inbox-item { display:flex; align-items:center; gap:0.65rem; padding:0.65rem 0.75rem; border-radius:8px; background:var(--color-secondary); cursor:pointer; transition:opacity 0.15s; }
.inbox-item:hover { opacity:0.85; }
.inbox-avatar { width:36px; height:36px; border-radius:50%; background:var(--color-card); display:flex; align-items:center; justify-content:center; font-size:0.9rem; flex-shrink:0; overflow:hidden; }
.inbox-avatar img { width:100%; height:100%; object-fit:cover; }
.inbox-info { flex:1; min-width:0; }
.inbox-name { font-weight:600; font-size:0.88rem; }
.inbox-preview { font-size:0.78rem; color:var(--color-muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.inbox-unread { background:var(--color-primary); color:#fff; font-size:0.65rem; font-weight:700; border-radius:9999px; padding:0.1rem 0.35rem; flex-shrink:0; }
/* Posteingang Chat */
.inbox-chat { display:none; flex-direction:column; gap:0; margin-top:0.75rem; border:1px solid var(--color-secondary); border-radius:10px; overflow:hidden; }
.inbox-chat.open { display:flex; }
.inbox-chat-header { display:flex; align-items:center; gap:0.5rem; padding:0.65rem 0.9rem; background:var(--color-secondary); font-weight:600; font-size:0.88rem; }
.inbox-chat-back { background:none; border:none; color:var(--color-muted); cursor:pointer; font-size:1.1rem; padding:0; margin:0; width:auto; line-height:1; }
.inbox-chat-close { background:none; border:none; color:var(--color-muted); cursor:pointer; font-size:1rem; padding:0.15rem 0.35rem; margin:0 0 0 auto; width:auto; line-height:1; border-radius:4px; transition:background 0.15s, color 0.15s; }
.inbox-chat-close:hover { background:var(--color-card); color:var(--color-text); }
.inbox-chat-messages { max-height:320px; overflow-y:auto; padding:0.75rem 1rem; display:flex; flex-direction:column; gap:0.4rem; }
.inbox-bubble-wrap { display:flex; flex-direction:column; }
.inbox-bubble-wrap.me { align-items:flex-end; }
.inbox-bubble-wrap.them { align-items:flex-start; }
.inbox-bubble { max-width:75%; padding:0.45rem 0.8rem; border-radius:12px; font-size:0.88rem; line-height:1.4; word-break:break-word; }
.inbox-bubble-wrap.me .inbox-bubble { background:var(--color-primary); color:#fff; border-bottom-right-radius:4px; }
.inbox-bubble-wrap.them .inbox-bubble { background:var(--color-secondary); color:var(--color-text); border-bottom-left-radius:4px; }
.inbox-bubble-time { font-size:0.68rem; color:var(--color-muted); margin-top:0.1rem; padding:0 0.2rem; }
.inbox-reply-area { display:flex; gap:0.5rem; padding:0.6rem 0.9rem; border-top:1px solid var(--color-secondary); align-items:center; }
.inbox-reply-area input { flex:1; }
.inbox-reply-btn { width:auto; margin-top:0; padding:0.5rem 1rem; flex-shrink:0; }
.inbox-lock-hint { font-size:0.8rem; color:var(--color-muted); padding:0.5rem 0.9rem; border-top:1px solid var(--color-secondary); background:var(--color-secondary); }
.inbox-reply-trigger { padding:0.6rem 0.9rem; border-top:1px solid var(--color-secondary); }
.inbox-reply-trigger .btn { font-size:0.85rem; }
</style>
</head>
<body>
<div class="main">
<a href="/community/locations.html" class="back-link">← Locations</a>
<body class="app">
<div id="content">
<p style="color:var(--color-muted);">Wird geladen…</p>
<!-- ── Admin-Autocomplete (außerhalb .main, damit overflow-y:auto nicht clippt) ── -->
<ul id="adminSearchList" 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>
<ul id="ownerSearchList" 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>
<div class="main">
<div class="content">
<a href="/community/locations.html" class="back-link">← Locations</a>
<div id="content">
<p style="color:var(--color-muted);">Wird geladen…</p>
</div>
</div>
</div>
@@ -131,7 +184,7 @@
<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>
<textarea id="eventDesc" 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>Datum &amp; Uhrzeit *</label>
<input type="datetime-local" id="eventStartAt">
<div class="modal-footer">
@@ -141,20 +194,45 @@
</div>
</div>
<!-- ── Hinweis-Modal ──────────────────────────────────────────────────────── -->
<div class="modal-overlay" id="alertModal">
<div class="modal" style="width:min(380px,95vw);">
<p id="alertMessage" style="margin:0 0 1.25rem;font-size:0.95rem;"></p>
<div class="modal-footer">
<button class="btn" onclick="document.getElementById('alertModal').classList.remove('open')">OK</button>
</div>
</div>
</div>
<!-- ── Bestätigungs-Modal ─────────────────────────────────────────────────── -->
<div class="modal-overlay" id="confirmModal">
<div class="modal" style="width:min(380px,95vw);">
<h3 id="confirmTitle">Bestätigung</h3>
<p id="confirmMessage" style="color:var(--color-muted);font-size:0.92rem;margin:0 0 0.25rem;"></p>
<div class="modal-footer">
<button class="btn" style="background:var(--color-secondary);color:var(--color-text);" onclick="closeConfirm()">Abbrechen</button>
<button class="btn" id="confirmOkBtn" style="background:#c0392b;">Löschen</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="">
<button class="lb-nav lb-prev" id="lbPrev" onclick="event.stopPropagation();lbNav(-1)">&#8249;</button>
<img id="lbImg" src="" alt="" onclick="event.stopPropagation()">
<button class="lb-nav lb-next" id="lbNext" onclick="event.stopPropagation();lbNav(1)">&#8250;</button>
</div>
<script src="/js/sidebar.js"></script>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
<script>
const params = new URLSearchParams(location.search);
const locationId = params.get('id');
let locDetail = null;
let myUserId = null;
let isOwner = false;
let isAdmin = false;
let isFollowing = false;
// ── Bild-Resize ───────────────────────────────────────────────────────────────
@@ -192,6 +270,18 @@ function formatDate(dt) {
+ ', ' + d.toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' }) + ' Uhr';
}
// ── Tabs ──────────────────────────────────────────────────────────────────────
const VALID_TABS = ['grunddaten', 'admins', 'posteingang', 'veranstaltungen'];
function switchTab(name) {
if (!VALID_TABS.includes(name)) name = 'grunddaten';
document.querySelectorAll('.tab-btn').forEach(btn =>
btn.classList.toggle('active', btn.dataset.tab === name));
document.querySelectorAll('.tab-panel').forEach(panel =>
panel.classList.toggle('active', panel.id === 'tab-' + name));
history.replaceState(null, '', location.pathname + location.search + '#' + name);
}
// ── Lade Seite ────────────────────────────────────────────────────────────────
async function loadPage() {
if (!locationId) { document.getElementById('content').innerHTML = '<p>Keine Location-ID angegeben.</p>'; return; }
@@ -207,12 +297,24 @@ async function loadPage() {
myUserId = me.userId;
}
locDetail = await locRes.json();
isOwner = locDetail.ownerId === myUserId;
locDetail = await locRes.json();
isOwner = locDetail.ownerId === myUserId;
isAdmin = isOwner || !!locDetail.isAdmin;
isFollowing = !!locDetail.following;
renderPage();
loadEvents();
if (isAdmin) {
const chatWithId = new URLSearchParams(location.search).get('chatWith');
const hash = location.hash.replace('#', '');
const initialTab = chatWithId ? 'posteingang' : (VALID_TABS.includes(hash) ? hash : 'grunddaten');
switchTab(initialTab);
renderAdminList();
loadInbox();
loadEvents();
} else {
loadEvents();
}
}
function renderPage() {
@@ -230,6 +332,7 @@ function renderPage() {
<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>
${loc.virtualUserId && myUserId && !isAdmin ? `<button class="btn" style="font-size:0.85rem;" onclick="contactLocation('${loc.virtualUserId}')">✉ Kontaktieren</button>` : ''}
</div>`;
let hoursHtml = '';
@@ -248,7 +351,7 @@ function renderPage() {
const galleryHtml = buildGalleryHtml(loc.gallery || []);
document.getElementById('content').innerHTML = `
const locHeaderHtml = `
<div class="loc-header">
<div class="loc-avatar">${imgHtml}</div>
<div class="loc-meta">
@@ -257,10 +360,9 @@ function renderPage() {
${loc.description ? `<div class="loc-desc">${escHtml(loc.description)}</div>` : ''}
${ownerActions}
</div>
</div>
${hoursHtml}
</div>`;
const gallerySection = `
<div class="section-title">
Galerie
${isOwner ? `<label class="btn" style="font-size:0.8rem;cursor:pointer;">
@@ -268,14 +370,87 @@ function renderPage() {
<input type="file" accept="image/*" style="display:none;" onchange="uploadGalleryImage(this)">
</label>` : ''}
</div>
<div class="gallery-grid" id="galleryGrid">${galleryHtml}</div>
<div class="gallery-grid" id="galleryGrid">${galleryHtml}</div>`;
const eventsSection = `
<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>
`;
<div id="pastEventsSection" style="display:none;">
<div class="section-title" style="margin-top:1.5rem;">Vergangene Veranstaltungen</div>
<div class="event-list" id="pastEventList"></div>
</div>`;
if (isAdmin) {
document.getElementById('content').innerHTML = `
<div class="tab-bar">
<button class="tab-btn" data-tab="grunddaten" onclick="switchTab('grunddaten')">Grunddaten</button>
<button class="tab-btn" data-tab="admins" onclick="switchTab('admins')">Administrator*Innen</button>
<button class="tab-btn" data-tab="posteingang" onclick="switchTab('posteingang')">Posteingang</button>
<button class="tab-btn" data-tab="veranstaltungen" onclick="switchTab('veranstaltungen')">Veranstaltungen</button>
</div>
<div class="tab-panel" id="tab-grunddaten">
${locHeaderHtml}
${hoursHtml}
${gallerySection}
</div>
<div class="tab-panel" id="tab-admins">
<div id="adminList" style="display:flex;flex-direction:column;gap:0.5rem;margin-bottom:0.75rem;"></div>
<div style="display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap;">
<div style="flex:1;min-width:180px;">
<input type="text" id="adminSearchInput" placeholder="Mitglied suchen…" autocomplete="off"
oninput="onAdminSearch()" style="width:100%;box-sizing:border-box;">
</div>
<button class="btn" style="font-size:0.85rem;white-space:nowrap;" onclick="addAdminFromSearch()">+ Admin hinzufügen</button>
</div>
${isOwner ? `
<div style="margin-top:1rem;padding-top:1rem;border-top:1px solid var(--color-secondary);">
<div style="font-size:0.82rem;color:var(--color-muted);margin-bottom:0.5rem;">Inhaberrechte übertragen</div>
<div style="display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap;">
<div style="flex:1;min-width:180px;">
<input type="text" id="ownerSearchInput" placeholder="Mitglied suchen…" autocomplete="off"
oninput="onOwnerSearch()" style="width:100%;box-sizing:border-box;">
</div>
<button class="btn" style="font-size:0.85rem;white-space:nowrap;background:#c0392b;" onclick="transferOwner()">Inhaberwechsel</button>
</div>
</div>` : ''}
</div>
<div class="tab-panel" id="tab-posteingang">
<div id="inboxList" class="inbox-list"><p style="color:var(--color-muted);font-size:0.9rem;">Wird geladen…</p></div>
<div class="inbox-chat" id="inboxChat">
<div class="inbox-chat-header">
<button class="inbox-chat-back" onclick="closeInboxChat()" aria-label="Zurück"></button>
<span id="inboxChatName" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></span>
<button class="inbox-chat-close" onclick="closeInboxChat()" aria-label="Schließen">✕</button>
</div>
<div class="inbox-chat-messages" id="inboxChatMessages"></div>
<div class="inbox-lock-hint" id="inboxLockHint" style="display:none;"></div>
<div class="inbox-reply-trigger" id="inboxReplyTrigger" style="display:none;">
<button class="btn" onclick="startReplying()">✎ Antworten</button>
</div>
<div class="inbox-reply-area" id="inboxReplyArea" style="display:none;">
<input type="text" id="inboxReplyInput" placeholder="Antwort eingeben…" autocomplete="off"
onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendInboxReply();}">
<button class="btn inbox-reply-btn" onclick="sendInboxReply()">↑</button>
</div>
</div>
</div>
<div class="tab-panel" id="tab-veranstaltungen">
${eventsSection}
</div>`;
} else {
document.getElementById('content').innerHTML = `
${locHeaderHtml}
${hoursHtml}
${gallerySection}
${eventsSection}`;
}
}
function buildGalleryHtml(gallery) {
@@ -298,7 +473,7 @@ async function uploadGalleryImage(input) {
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ imageData })
});
if (res.status === 422) { alert('Maximal 20 Galeriebilder erlaubt.'); return; }
if (res.status === 422) { showAlert('Maximal 20 Galeriebilder erlaubt.'); return; }
if (!res.ok) throw new Error();
const img = await res.json();
const grid = document.getElementById('galleryGrid');
@@ -307,18 +482,34 @@ async function uploadGalleryImage(input) {
<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.'); }
} catch { showAlert('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; }
if (!res.ok) { showAlert('Fehler beim Löschen.'); return; }
await loadPage();
}
// ── Events ─────────────────────────────────────────────────────────────────────
function buildEventCard(e, isPast) {
const imgHtml = e.imageData
? `<img src="data:image/jpeg;base64,${e.imageData}" alt="${escHtml(e.title)}">`
: '🗓';
const opacity = isPast ? 'opacity:0.6;' : '';
return `
<a class="event-card" href="/community/event-detail.html?id=${e.eventId}" style="${opacity}">
<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>
</div>
</a>`;
}
async function loadEvents() {
const res = await fetch(`/locations/${locationId}/events`);
if (!res.ok) return;
@@ -326,37 +517,50 @@ async function loadEvents() {
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;
}
const now = new Date();
const future = events.filter(e => new Date(e.startAt) >= now);
const past = events.filter(e => new Date(e.startAt) < now)
.slice(-5) // letzte 5
.reverse(); // neueste zuerst
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('');
list.innerHTML = future.length
? future.map(e => buildEventCard(e, false)).join('')
: '<p style="color:var(--color-muted);font-size:0.9rem;">Keine bevorstehenden Veranstaltungen.</p>';
const pastSection = document.getElementById('pastEventsSection');
if (past.length && pastSection) {
document.getElementById('pastEventList').innerHTML = past.map(e => buildEventCard(e, true)).join('');
pastSection.style.display = '';
} else if (pastSection) {
pastSection.style.display = 'none';
}
}
// ── Lightbox ───────────────────────────────────────────────────────────────────
let lbSrcs = [], lbIdx = 0;
function openLightbox(src) {
document.getElementById('lbImg').src = src;
lbSrcs = Array.from(document.querySelectorAll('#galleryGrid .gallery-img-wrap img')).map(i => i.src);
lbIdx = Math.max(0, lbSrcs.indexOf(src));
lbShow();
document.getElementById('lightbox').classList.add('open');
}
function lbShow() {
document.getElementById('lbImg').src = lbSrcs[lbIdx];
document.getElementById('lbPrev').disabled = lbIdx === 0;
document.getElementById('lbNext').disabled = lbIdx === lbSrcs.length - 1;
}
function lbNav(dir) {
lbIdx = Math.max(0, Math.min(lbSrcs.length - 1, lbIdx + dir));
lbShow();
}
function closeLightbox() { document.getElementById('lightbox').classList.remove('open'); }
document.addEventListener('keydown', e => {
if (!document.getElementById('lightbox').classList.contains('open')) return;
if (e.key === 'Escape') closeLightbox();
else if (e.key === 'ArrowLeft') lbNav(-1);
else if (e.key === 'ArrowRight') lbNav(1);
});
// ── Edit Modal ─────────────────────────────────────────────────────────────────
let _editLq = null, _editHq = null, _editLat = null, _editLon = null, _editStreet = null, _editCity = null, _editCityTimer = null;
@@ -489,7 +693,7 @@ document.addEventListener('click', e => {
async function submitEdit() {
const name = document.getElementById('editName').value.trim();
if (!name) { alert('Name darf nicht leer sein.'); return; }
if (!name) { showAlert('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.';
@@ -530,7 +734,7 @@ async function submitEdit() {
closeEditModal();
renderPage();
loadEvents();
} catch { alert('Fehler beim Speichern.'); }
} catch { showAlert('Fehler beim Speichern.'); }
finally { btn.disabled = false; btn.textContent = 'Speichern'; }
}
@@ -547,14 +751,14 @@ async function toggleFollow() {
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.'); }
} catch (_) { showAlert('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; }
if (!res.ok) { showAlert('Fehler beim Löschen.'); return; }
window.location.href = '/community/locations.html';
}
@@ -568,6 +772,9 @@ function openEventModal(evtId) {
document.getElementById('eventTitle').value = '';
document.getElementById('eventDesc').value = '';
document.getElementById('eventStartAt').value = '';
// Nur Termine in der Zukunft erlauben
const nowLocal = new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().slice(0, 16);
document.getElementById('eventStartAt').min = nowLocal;
document.getElementById('eventPicPreview').innerHTML = '🗓';
document.getElementById('eventModal').classList.add('open');
}
@@ -582,8 +789,9 @@ async function onEventPicChange(input) {
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; }
if (!title) { showAlert('Bitte gib einen Titel ein.'); return; }
if (!startAt) { showAlert('Bitte wähle Datum und Uhrzeit.'); return; }
if (new Date(startAt) <= new Date()) { showAlert('Der Termin muss in der Zukunft liegen.'); return; }
const btn = document.getElementById('eventSubmitBtn');
btn.disabled = true; btn.textContent = 'Wird gespeichert…';
@@ -610,15 +818,321 @@ async function submitEvent() {
closeEventModal();
loadEvents();
} catch { alert('Fehler beim Speichern.'); }
} catch { showAlert('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();
function showAlert(message) {
document.getElementById('alertMessage').textContent = message;
document.getElementById('alertModal').classList.add('open');
}
function openConfirm(title, message, onOk) {
document.getElementById('confirmTitle').textContent = title;
document.getElementById('confirmMessage').textContent = message;
const btn = document.getElementById('confirmOkBtn');
btn.onclick = () => { closeConfirm(); onOk(); };
document.getElementById('confirmModal').classList.add('open');
}
function closeConfirm() { document.getElementById('confirmModal').classList.remove('open'); }
// ── Admin-Verwaltung ──────────────────────────────────────────────────────────
let _adminSearchSelected = null;
let _ownerSearchSelected = null;
let _adminSearchTimer = null;
let _ownerSearchTimer = null;
function renderAdminList() {
const list = document.getElementById('adminList');
if (!list || !locDetail.admins) return;
list.innerHTML = locDetail.admins.map(a => {
const pic = a.profilePicture
? `<img src="data:image/jpeg;base64,${a.profilePicture}" alt="" style="width:100%;height:100%;object-fit:cover;border-radius:50%;">`
: '◉';
const badge = a.isOwner
? `<span style="font-size:0.7rem;background:var(--color-primary);color:#fff;border-radius:4px;padding:0.1rem 0.4rem;margin-left:0.4rem;">Inhaber</span>`
: '';
const removeBtn = isAdmin && !a.isOwner
? `<button onclick="removeAdmin('${a.userId}')" style="margin-left:auto;background:none;border:none;color:var(--color-muted);cursor:pointer;font-size:1rem;padding:0.2rem 0.4rem;" title="Entfernen">✕</button>`
: '';
return `<div style="display:flex;align-items:center;gap:0.6rem;padding:0.4rem 0;">
<div style="width:32px;height:32px;border-radius:50%;background:var(--color-secondary);display:flex;align-items:center;justify-content:center;font-size:1rem;overflow:hidden;flex-shrink:0;">${pic}</div>
<span style="font-size:0.9rem;">${escHtml(a.name)}</span>${badge}${removeBtn}
</div>`;
}).join('');
}
async function removeAdmin(userId) {
const res = await fetch(`/locations/${locationId}/admins/${userId}`, { method: 'DELETE' });
if (!res.ok) { showAlert('Fehler beim Entfernen.'); return; }
locDetail.admins = locDetail.admins.filter(a => a.userId !== userId);
renderAdminList();
}
function onAdminSearch() {
const q = document.getElementById('adminSearchInput').value.trim();
_adminSearchSelected = null;
clearTimeout(_adminSearchTimer);
if (q.length < 2) { document.getElementById('adminSearchList').style.display = 'none'; return; }
_adminSearchTimer = setTimeout(() => fetchUserSuggestions(q, 'adminSearchList', sel => { _adminSearchSelected = sel; }), 250);
}
function onOwnerSearch() {
const q = document.getElementById('ownerSearchInput').value.trim();
_ownerSearchSelected = null;
clearTimeout(_ownerSearchTimer);
if (q.length < 2) { document.getElementById('ownerSearchList').style.display = 'none'; return; }
_ownerSearchTimer = setTimeout(() => fetchUserSuggestions(q, 'ownerSearchList', sel => { _ownerSearchSelected = sel; }), 250);
}
const _userCache = {};
async function fetchUserSuggestions(q, listId, onSelect) {
try {
const res = await fetch(`/social/users/search?q=${encodeURIComponent(q)}`);
if (!res.ok) return;
const users = await res.json();
const ul = document.getElementById(listId);
if (!users.length) { ul.style.display = 'none'; return; }
users.forEach(u => { _userCache[u.userId] = u; });
ul.innerHTML = users.map(u => `
<li style="padding:0.45rem 0.75rem;cursor:pointer;font-size:0.9rem;border-bottom:1px solid var(--color-secondary);"
onmousedown="event.preventDefault();selectUserSuggestion('${listId}','${u.userId}')">
${escHtml(u.name)}
</li>`).join('');
ul.__selectFn = onSelect;
// Dropdown unter dem zugehörigen Input positionieren
const inputId = listId === 'adminSearchList' ? 'adminSearchInput' : 'ownerSearchInput';
const rect = document.getElementById(inputId).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 selectUserSuggestion(listId, userId) {
const ul = document.getElementById(listId);
const inputId = listId === 'adminSearchList' ? 'adminSearchInput' : 'ownerSearchInput';
const user = _userCache[userId];
if (!user) return;
document.getElementById(inputId).value = user.name;
ul.style.display = 'none';
if (ul.__selectFn) ul.__selectFn(user);
}
document.addEventListener('click', e => {
['adminSearchList','ownerSearchList'].forEach(id => {
const ul = document.getElementById(id);
if (ul && !e.target.closest('#' + id) && e.target.id !== (id === 'adminSearchList' ? 'adminSearchInput' : 'ownerSearchInput'))
ul.style.display = 'none';
});
});
async function addAdminFromSearch() {
if (!_adminSearchSelected) { showAlert('Bitte wähle ein Mitglied aus der Liste.'); return; }
const res = await fetch(`/locations/${locationId}/admins`, {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ userId: _adminSearchSelected.userId })
});
if (res.status === 409) { showAlert('Diese Person ist bereits Admin.'); return; }
if (!res.ok) { showAlert('Fehler beim Hinzufügen.'); return; }
const added = await res.json();
locDetail.admins = [...(locDetail.admins || []), added];
renderAdminList();
document.getElementById('adminSearchInput').value = '';
_adminSearchSelected = null;
}
async function transferOwner() {
if (!_ownerSearchSelected) { showAlert('Bitte wähle ein Mitglied aus der Liste.'); return; }
openConfirm(
'Inhaberwechsel',
`Inhaberrechte wirklich an „${_ownerSearchSelected.name}" übertragen? Diese Aktion kann nicht rückgängig gemacht werden.`,
async () => {
const res = await fetch(`/locations/${locationId}/admins/transfer-owner`, {
method: 'PUT',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ userId: _ownerSearchSelected.userId })
});
if (!res.ok) { showAlert('Fehler beim Inhaberwechsel.'); return; }
// Seite neu laden aktueller User ist jetzt kein Inhaber mehr
await loadPage();
}
);
}
// ── Kontaktieren ──────────────────────────────────────────────────────────────
function contactLocation(virtualUserId) {
window.location.href = '/community/nachrichten.html?partnerId=' + virtualUserId;
}
// ── Posteingang (Admin) ───────────────────────────────────────────────────────
let _inboxPartnerId = null;
async function loadInbox() {
const el = document.getElementById('inboxList');
if (!el) return;
try {
const res = await fetch(`/locations/${locationId}/inbox`);
if (!res.ok) { el.innerHTML = '<p style="color:var(--color-muted);font-size:0.9rem;">Nicht verfügbar.</p>'; return; }
const items = await res.json();
if (items.length === 0) {
el.innerHTML = '<p style="color:var(--color-muted);font-size:0.9rem;">Noch keine Nachrichten.</p>';
return;
}
el.innerHTML = items.map(item => {
const av = item.senderPicture
? `<img src="data:image/png;base64,${item.senderPicture}" alt="">`
: '◉';
const unreadHtml = item.unreadCount > 0
? `<span class="inbox-unread">${item.unreadCount}</span>` : '';
return `<div class="inbox-item" onclick="openInboxChat('${item.senderId}','${escHtml(item.senderName)}')">
<div class="inbox-avatar">${av}</div>
<div class="inbox-info">
<div class="inbox-name">${escHtml(item.senderName)}</div>
<div class="inbox-preview">${escHtml(item.lastMessage)}</div>
</div>
${unreadHtml}
</div>`;
}).join('');
// URL-Parameter: Chat direkt öffnen (z.B. nach Klick auf Benachrichtigung)
const chatWithId = new URLSearchParams(location.search).get('chatWith');
if (chatWithId && !_inboxPartnerId) {
const match = items.find(i => i.senderId === chatWithId);
if (match) openInboxChat(match.senderId, match.senderName);
}
} catch { el.innerHTML = '<p style="color:var(--color-muted);font-size:0.9rem;">Fehler beim Laden.</p>'; }
}
async function openInboxChat(partnerId, partnerName) {
_inboxPartnerId = partnerId;
document.getElementById('inboxChatName').textContent = partnerName;
document.getElementById('inboxChatMessages').innerHTML = '<p style="color:var(--color-muted);font-size:0.85rem;text-align:center;">Wird geladen…</p>';
document.getElementById('inboxChat').classList.add('open');
document.getElementById('inboxReplyInput').value = '';
// Eingabebereich zurücksetzen
document.getElementById('inboxReplyTrigger').style.display = 'none';
document.getElementById('inboxReplyArea').style.display = 'none';
document.getElementById('inboxLockHint').style.display = 'none';
const virtualId = locDetail.virtualUserId;
try {
const res = await fetch(`/locations/${locationId}/inbox/${partnerId}`);
if (!res.ok) throw new Error();
const data = await res.json();
const messages = data.messages || [];
const container = document.getElementById('inboxChatMessages');
container.innerHTML = '';
if (messages.length === 0) {
container.innerHTML = '<p style="color:var(--color-muted);font-size:0.85rem;text-align:center;">Noch keine Nachrichten.</p>';
} else {
messages.forEach(m => {
const isLocationSender = m.senderId === virtualId;
const wrap = document.createElement('div');
wrap.className = 'inbox-bubble-wrap ' + (isLocationSender ? 'me' : 'them');
const time = new Date(m.sentAt).toLocaleTimeString('de', { hour: '2-digit', minute: '2-digit' });
wrap.innerHTML = `<div class="inbox-bubble">${escHtml(m.text)}</div><div class="inbox-bubble-time">${time}</div>`;
container.appendChild(wrap);
});
}
requestAnimationFrame(() => { container.scrollTop = container.scrollHeight; });
// Lock-Status anzeigen
applyLockUi(data);
loadInbox();
} catch {
document.getElementById('inboxChatMessages').innerHTML = '<p style="color:var(--color-muted);font-size:0.85rem;text-align:center;">Fehler beim Laden.</p>';
}
}
function applyLockUi(data) {
const trigger = document.getElementById('inboxReplyTrigger');
const area = document.getElementById('inboxReplyArea');
const lockHint = document.getElementById('inboxLockHint');
if (data.lockedByMe) {
// Ich habe die Sperre → Eingabefeld direkt zeigen
trigger.style.display = 'none';
area.style.display = '';
lockHint.style.display = 'none';
document.getElementById('inboxReplyInput').focus();
} else if (data.canReply) {
// Frei → "Antworten"-Button zeigen
trigger.style.display = '';
area.style.display = 'none';
lockHint.style.display = 'none';
} else {
// Gesperrt durch anderen Admin
trigger.style.display = 'none';
area.style.display = 'none';
lockHint.style.display = '';
lockHint.textContent = `Wird gerade von ${data.lockedByName || 'einem anderen Admin'} beantwortet.`;
}
}
async function startReplying() {
if (!_inboxPartnerId) return;
try {
const res = await fetch(`/locations/${locationId}/inbox/${_inboxPartnerId}/lock`, {
method: 'POST'
});
if (res.status === 409) {
const body = await res.json();
const name = body.lockedByName || 'einem anderen Admin';
document.getElementById('inboxReplyTrigger').style.display = 'none';
document.getElementById('inboxLockHint').style.display = '';
document.getElementById('inboxLockHint').textContent =
`Wird gerade von ${name} beantwortet.`;
return;
}
if (!res.ok) { showAlert('Sperre konnte nicht erworben werden.'); return; }
document.getElementById('inboxReplyTrigger').style.display = 'none';
document.getElementById('inboxReplyArea').style.display = '';
document.getElementById('inboxReplyInput').focus();
} catch { showAlert('Fehler beim Sperren.'); }
}
function closeInboxChat() {
_inboxPartnerId = null;
document.getElementById('inboxChat').classList.remove('open');
document.getElementById('inboxReplyTrigger').style.display = 'none';
document.getElementById('inboxReplyArea').style.display = 'none';
document.getElementById('inboxLockHint').style.display = 'none';
loadInbox();
}
async function sendInboxReply() {
if (!_inboxPartnerId) return;
const input = document.getElementById('inboxReplyInput');
const text = input.value.trim();
if (!text) return;
input.value = '';
try {
const res = await fetch(`/locations/${locationId}/inbox/${_inboxPartnerId}/reply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text })
});
if (res.status === 409) {
const body = await res.json().catch(() => ({}));
const name = body.lockedByName || 'einem anderen Admin';
input.value = text;
// Eingabe sperren, Hinweis zeigen
document.getElementById('inboxReplyArea').style.display = 'none';
document.getElementById('inboxLockHint').style.display = '';
document.getElementById('inboxLockHint').textContent =
`Wird gerade von ${name} beantwortet. Deine Nachricht wurde nicht gesendet.`;
return;
}
if (!res.ok) { showAlert('Fehler beim Senden.'); input.value = text; return; }
// Konversation neu laden (Lock ist nach Senden bei mir)
const partnerName = document.getElementById('inboxChatName').textContent;
await openInboxChat(_inboxPartnerId, partnerName);
} catch { showAlert('Fehler beim Senden.'); input.value = text; }
}
// ── Init ──────────────────────────────────────────────────────────────────────