Files
xxx-sphere-web/bin/main/static/community/location-detail.html
Mario e35b095c18
Some checks failed
Host-Based Deploy (Java 21 Fix) / build-and-run (push) Has been cancelled
Locations können nun auch Posten - bugfixes im Feed
2026-04-13 23:04:15 +02:00

1510 lines
77 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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">
<link rel="stylesheet" href="/css/community.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: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; }
.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(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; }
.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: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 class="app">
<!-- ── 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>
<!-- ── 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;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">
<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>
<!-- ── 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>
<!-- ── Post-Lightbox ──────────────────────────────────────────────────────── -->
<div class="lightbox" id="postLightbox">
<div class="lb-layout">
<div class="lb-post-side">
<div id="lbPostBody"></div>
</div>
<div class="lb-comments-panel">
<div class="lb-comments-header">Kommentare</div>
<div class="lb-comments-list" id="lbCommentsList"></div>
<div class="lb-comment-compose">
<textarea id="lbCommentInput" placeholder="Kommentar schreiben…" maxlength="500" rows="3"
onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();postLbComment()}"></textarea>
<div class="lb-comment-compose-actions">
<button type="button" class="compose-action-btn" onclick="toggleEmojiPicker(this,'lbCommentInput')" title="Emoji">😊</button>
<button onclick="postLbComment()">Senden</button>
</div>
</div>
</div>
<button class="lb-close" onclick="closeLb()"></button>
</div>
</div>
<!-- ── Galerie Lightbox ───────────────────────────────────────────────────── -->
<div class="lb" id="lightbox" onclick="closeLightbox()">
<button class="lb-close" onclick="closeLightbox()"></button>
<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/icons.js"></script>
<script src="/js/nav.js"></script>
<script src="/js/hashtag.js"></script>
<script src="/js/shared.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 ───────────────────────────────────────────────────────────────
function resizeImage(file, maxPx, quality) {
return new Promise((resolve, reject) => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(url);
let w = img.naturalWidth, h = img.naturalHeight;
if (w > maxPx || h > maxPx) {
if (w >= h) { h = Math.max(1, Math.round(maxPx * h / w)); w = maxPx; }
else { w = Math.max(1, Math.round(maxPx * w / h)); h = maxPx; }
}
const canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
resolve(canvas.toDataURL('image/jpeg', quality || 0.85).split(',')[1]);
};
img.onerror = reject;
img.src = url;
});
}
function escHtml(s) {
return (s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
const DAY_NAMES = ['Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag','Sonntag'];
function formatDate(dt) {
if (!dt) return '';
const d = new Date(dt);
return d.toLocaleDateString('de-DE', { weekday:'short', day:'2-digit', month:'2-digit', year:'numeric' })
+ ', ' + d.toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' }) + ' Uhr';
}
// ── 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; }
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;
isAdmin = isOwner || !!locDetail.isAdmin;
isFollowing = !!locDetail.following;
renderPage();
initLb(myUserId);
const _feedTa = document.getElementById('locFeedText');
if (_feedTa) attachHashtagAutocomplete(_feedTa);
const _compose = document.getElementById('locFeedCompose');
if (_compose) {
_compose.addEventListener('dragover', e => { e.preventDefault(); _compose.classList.add('drag-over'); });
_compose.addEventListener('dragleave', e => { if (!_compose.contains(e.relatedTarget)) _compose.classList.remove('drag-over'); });
_compose.addEventListener('drop', e => { e.preventDefault(); _compose.classList.remove('drag-over'); [...e.dataTransfer.files].filter(f => f.type.startsWith('image/')).forEach(f => processImageFile(f, locFeedImages, renderLocFeedThumbs)); });
}
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();
}
await loadLocFeed();
initLocFeedObserver();
}
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>
${loc.virtualUserId && myUserId && !isAdmin ? `<button class="btn" style="font-size:0.85rem;" onclick="contactLocation('${loc.virtualUserId}')">✉ Kontaktieren</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 || []);
const locHeaderHtml = `
<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>`;
const gallerySection = `
<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>`;
const feedComposeHtml = isAdmin ? `
<div class="post-compose" id="locFeedCompose">
<textarea id="locFeedText" placeholder="Was möchtest du teilen?" rows="3"
oninput="locFeedTextInput(this)" onpaste="locFeedOnPaste(event)"></textarea>
<div class="compose-thumbs" id="locFeedThumbs"></div>
<div class="umfrage-options" id="locUmfrageOptions" style="display:none;">
<div id="locOptionList"></div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:0.5rem;">
<button onclick="addLocOption()" style="width:auto;margin:0;padding:0.3rem 0.75rem;font-size:0.8rem;">+ Option</button>
<label class="multi-toggle"><input type="checkbox" id="locMultiChoice"> Mehrfachauswahl möglich</label>
</div>
</div>
<div class="compose-footer">
<div style="display:flex;gap:0.5rem;align-items:center;margin-left:auto;">
<button type="button" class="compose-action-btn" onclick="toggleEmojiPicker(this,'locFeedText')" title="Emoji">😊</button>
<label class="compose-action-btn" title="Fotos">📷
<input type="file" id="locFeedBildFile" accept="image/*" multiple style="display:none;"
onchange="locFeedAddImages(this)">
</label>
<button type="button" id="locUmfrageBtn" class="compose-action-btn" onclick="toggleLocUmfrage(this)" title="Umfrage">📊</button>
<button onclick="submitLocFeedPost()" style="width:auto;margin:0;">Veröffentlichen</button>
</div>
</div>
</div>` : '';
const feedSection = `
<div class="section-title" style="margin-top:1.5rem;">Beiträge</div>
${feedComposeHtml}
<div id="locFeedList"></div>
<div class="sentinel" id="locFeedSentinel"></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}
${feedSection}
</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}
${feedSection}
${eventsSection}`;
}
}
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) { showAlert('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 { 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) { 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;
const events = await res.json();
const list = document.getElementById('eventList');
if (!list) 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 = 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) {
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;
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) { 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.';
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 { showAlert('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 (_) { 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) { showAlert('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 = '';
// 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');
}
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) { 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…';
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 { showAlert('Fehler beim Speichern.'); }
finally { btn.disabled = false; btn.textContent = 'Speichern'; }
}
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; }
}
// ── Location Feed ─────────────────────────────────────────────────────────────
const locPostCache = {};
const locPostBilder = new Map();
const locEditBilder = new Map();
let locFeedPage = 0;
let locFeedHasMore = true;
let locFeedLoading = false;
let locFeedImages = [];
function locFeedTextInput(ta) {
ta.style.height = 'auto';
ta.style.height = Math.min(ta.scrollHeight, 220) + 'px';
}
function locFeedOnPaste(e) {
const items = e.clipboardData?.items;
if (!items) return;
for (const item of items) {
if (item.type.startsWith('image/')) {
e.preventDefault();
processImageFile(item.getAsFile(), locFeedImages, renderLocFeedThumbs);
}
}
}
function locFeedAddImages(input) {
const files = Array.from(input.files || []);
files.forEach(f => processImageFile(f, locFeedImages, renderLocFeedThumbs));
input.value = '';
}
function renderLocFeedThumbs() {
const wrap = document.getElementById('locFeedThumbs');
if (!wrap) return;
wrap.style.display = locFeedImages.length ? 'flex' : 'none';
wrap.innerHTML = locFeedImages.map((b64, i) => `
<div class="compose-thumb">
<img src="data:image/jpeg;base64,${b64}" alt="">
<button class="compose-thumb-remove" onclick="locFeedRemoveImg(${i})">✕</button>
</div>`).join('');
}
function locFeedRemoveImg(idx) {
locFeedImages.splice(idx, 1);
renderLocFeedThumbs();
}
function toggleLocUmfrage(btn) {
const opt = document.getElementById('locUmfrageOptions');
if (!opt) return;
const showing = opt.style.display !== 'none';
opt.style.display = showing ? 'none' : '';
if (btn) btn.classList.toggle('active', !showing);
if (!showing && document.getElementById('locOptionList').children.length === 0) { addLocOption(); addLocOption(); }
}
function resetLocUmfrage() {
const opt = document.getElementById('locUmfrageOptions');
if (opt) opt.style.display = 'none';
const list = document.getElementById('locOptionList');
if (list) list.innerHTML = '';
const btn = document.getElementById('locUmfrageBtn');
if (btn) btn.classList.remove('active');
}
function addLocOption() {
const list = document.getElementById('locOptionList');
if (!list) return;
const row = document.createElement('div'); row.className = 'umfrage-option-row';
row.innerHTML = `<input type="text" placeholder="Option ${list.children.length + 1}" maxlength="100">
<button onclick="this.parentElement.remove()">✕</button>`;
list.appendChild(row);
}
async function submitLocFeedPost() {
const text = (document.getElementById('locFeedText')?.value || '').trim();
const hasUmfrage = document.getElementById('locUmfrageOptions')?.style.display !== 'none';
if (!text && locFeedImages.length === 0) return;
const multiChoice = document.getElementById('locMultiChoice')?.checked || false;
let optionen = [];
if (hasUmfrage) {
optionen = Array.from(document.getElementById('locOptionList')?.querySelectorAll('input') || [])
.map(i => i.value.trim()).filter(v => v);
if (optionen.length < 2) { alert('Mindestens 2 Optionen erforderlich.'); return; }
}
const body = { beitragTyp: hasUmfrage ? 'UMFRAGE' : 'TEXT', text, multiChoice, optionen, bilder: locFeedImages, isPublic: true };
try {
const res = await fetch(`/feed/location/${locationId}/posts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) throw new Error();
const post = await res.json();
document.getElementById('locFeedText').value = '';
locFeedImages = [];
renderLocFeedThumbs();
resetLocUmfrage();
if (document.getElementById('locMultiChoice')) document.getElementById('locMultiChoice').checked = false;
const list = document.getElementById('locFeedList');
if (list) {
if (list.querySelector('.empty-hint')) list.innerHTML = '';
list.insertAdjacentHTML('afterbegin', renderLocPost(post));
}
} catch { showAlert('Fehler beim Posten.'); }
}
async function loadLocFeed() {
if (!locationId || locFeedLoading || !locFeedHasMore) return;
locFeedLoading = true;
try {
const res = await fetch(`/feed/location/${locationId}?page=${locFeedPage}&size=10`);
if (!res.ok) return;
const data = await res.json();
locFeedHasMore = data.hasMore;
locFeedPage++;
const list = document.getElementById('locFeedList');
if (!list) return;
if (locFeedPage === 1 && (!data.posts || data.posts.length === 0)) {
list.innerHTML = '<p class="empty-hint">Noch keine Beiträge.</p>';
return;
}
if (list.querySelector('.empty-hint')) list.innerHTML = '';
data.posts.forEach(p => {
list.insertAdjacentHTML('beforeend', renderLocPost(p));
});
} finally {
locFeedLoading = false;
}
}
function renderLocPost(p) {
locPostCache[p.postId] = p;
locPostBilder.set(p.postId, p.bilder || []);
const avHtml = p.authorPicture
? `<img src="data:image/jpeg;base64,${p.authorPicture}" alt="">`
: '📍';
const dateStr = new Date(p.createdAt).toLocaleDateString('de-DE', { day:'2-digit', month:'2-digit', year:'numeric' })
+ ' ' + new Date(p.createdAt).toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' });
const editedHtml = p.editedAt ? ' <span style="font-size:0.7rem;color:var(--color-muted);">(bearbeitet)</span>' : '';
const bildHtml = bilderGrid(p.bilder);
const onClickAttr = p.targetUrl
? ` onclick="window.location.href='${p.targetUrl}'"`
: ` onclick="openLpLb('${p.postId}')"`;
const clickableClass = ' clickable';
const adminBtns = isAdmin ? `
<div style="margin-left:auto;display:flex;gap:0.3rem;">
<button class="post-action-btn" onclick="event.stopPropagation();startLocEdit('${p.postId}')" title="Bearbeiten">✏</button>
<button class="post-action-btn post-delete" onclick="event.stopPropagation();deleteLocPost('${p.postId}')" title="Löschen">🗑</button>
</div>` : '';
return `<div class="post-card${clickableClass}" id="lp-${p.postId}"${onClickAttr}>
<div class="post-header">
<div class="post-avatar">${avHtml}</div>
<div>
<div class="post-author">${escHtml(p.authorName || p.locationName || '')}</div>
<div class="post-meta">${dateStr}${editedHtml}</div>
</div>
${adminBtns}
</div>
<div id="lpva-${p.postId}">
<div class="post-text">${renderTextWithHashtags(p.text || '')}</div>
<div id="lpbi-${p.postId}">${bildHtml}</div>
</div>
<div id="lpea-${p.postId}" style="display:none;"></div>
<div class="post-actions">
<button class="post-action-btn${p.likedByMe ? ' active' : ''}" onclick="event.stopPropagation();toggleLpLike('${p.postId}',this)">
♥ <span id="lplic-${p.postId}">${p.likeCount}</span>
</button>
<button class="post-action-btn" onclick="event.stopPropagation();openLpLb('${p.postId}')">
💬 ${p.kommentarCount}
</button>
</div>
</div>`;
}
async function toggleLpLike(postId, btn) {
const res = await fetch(`/feed/posts/${postId}/like`, { method: 'POST' });
if (!res.ok) return;
btn.classList.toggle('active');
const span = document.getElementById('lplic-' + postId);
if (span) span.textContent = parseInt(span.textContent) + (btn.classList.contains('active') ? 1 : -1);
}
function openLpLb(postId) {
const p = locPostCache[postId];
if (!p) return;
const avHtml = p.authorPicture
? `<img src="data:image/jpeg;base64,${p.authorPicture}" alt="">`
: '📍';
const dateStr = new Date(p.createdAt).toLocaleDateString('de-DE', { day:'2-digit', month:'2-digit', year:'numeric' })
+ ' ' + new Date(p.createdAt).toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' });
const editedHtml = p.editedAt ? ' <span style="font-size:0.7rem;color:var(--color-muted);">(bearbeitet)</span>' : '';
document.getElementById('lbPostBody').innerHTML = `
<div class="post-header">
<div class="post-avatar">${avHtml}</div>
<div>
<div class="post-author">${escHtml(p.authorName || p.locationName || '')}</div>
<div class="post-meta">${dateStr}${editedHtml}</div>
</div>
</div>
<div id="lpva-${p.postId}">
<div class="post-text">${renderTextWithHashtags(p.text || '')}</div>
<div id="lpbi-${p.postId}"></div>
</div>
<div class="post-actions" style="margin-top:0.75rem;">
<button class="post-action-btn${p.likedByMe ? ' active' : ''}" onclick="toggleLpLike('${p.postId}',this)">
♥ <span id="lplic-${p.postId}">${p.likeCount}</span>
</button>
</div>`;
_lbSetupContent(p.postId, 'lp', locPostBilder.get(p.postId) || []);
document.getElementById('postLightbox').classList.add('open');
document.body.style.overflow = 'hidden';
loadLbComments(p.postId, 'FEED');
}
async function deleteLocPost(postId) {
if (!confirm('Beitrag löschen?')) return;
const res = await fetch(`/feed/posts/${postId}`, { method: 'DELETE' });
if (!res.ok) { showAlert('Fehler beim Löschen.'); return; }
document.getElementById('lp-' + postId)?.remove();
delete locPostCache[postId];
}
function startLocEdit(postId) {
const p = locPostCache[postId];
if (!p) return;
const bilder = (p.bilder || []).slice();
locEditBilder.set(postId, bilder);
startPostEdit({
postId,
prefix: 'lp',
text: p.text || '',
bilder,
editBilderMap: locEditBilder,
beitragTyp: p.beitragTyp,
optionen: p.optionen || [],
multiChoice: p.multiChoice,
rmImgFn: `locEditRmImg('${postId}',IDX)`,
addImgFn: `locEditAddImg(this,'${postId}')`,
addOptionFn: `locEditAddOption('${postId}')`,
cancelFn: `cancelLocEdit('${postId}')`,
saveFn: `saveLocEdit('${postId}')`
});
}
function cancelLocEdit(postId) {
cancelPostEdit(postId, 'lp');
}
function locEditRmImg(postId, idx) {
const bilder = locEditBilder.get(postId);
if (bilder) bilder.splice(idx, 1);
_renderEditThumbs(locEditBilder, postId, 'lp', (pid, i) => locEditRmImg(pid, i));
}
function locEditAddImg(input, postId) {
const bilder = locEditBilder.get(postId) || [];
locEditBilder.set(postId, bilder);
Array.from(input.files || []).forEach(f =>
processImageFile(f, bilder, () => _renderEditThumbs(locEditBilder, postId, 'lp', (pid, i) => locEditRmImg(pid, i)))
);
input.value = '';
}
function locEditAddOption(postId) {
editAddOptionRow('lpeo-' + postId);
}
async function saveLocEdit(postId) {
await savePostEdit({
postId,
prefix: 'lp',
editBilderMap: locEditBilder,
endpoint: `/feed/posts/${postId}`,
onSuccess: (updated) => {
locPostCache[postId] = updated;
locPostBilder.set(postId, updated.bilder || []);
applyPostEditDom(updated, postId, 'lp', locPostBilder);
}
});
}
let _locFeedObserver = null;
function initLocFeedObserver() {
if (_locFeedObserver) return;
const s = document.getElementById('locFeedSentinel');
if (!s) return;
_locFeedObserver = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) loadLocFeed();
}, { threshold: 0.1 });
_locFeedObserver.observe(s);
}
// ── Init ──────────────────────────────────────────────────────────────────────
loadPage();
</script>
</body>
</html>