Locations können nun auch Posten - bugfixes im Feed
Some checks failed
Host-Based Deploy (Java 21 Fix) / build-and-run (push) Has been cancelled

This commit is contained in:
2026-04-13 23:04:15 +02:00
parent e2a71ab096
commit e35b095c18
53 changed files with 4186 additions and 2502 deletions

View File

@@ -7,6 +7,7 @@
<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); }
@@ -216,6 +217,28 @@
</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>
@@ -226,6 +249,8 @@
<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');
@@ -303,6 +328,15 @@ async function loadPage() {
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');
@@ -315,6 +349,8 @@ async function loadPage() {
} else {
loadEvents();
}
await loadLocFeed();
initLocFeedObserver();
}
function renderPage() {
@@ -372,6 +408,37 @@ function renderPage() {
</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
@@ -396,6 +463,7 @@ function renderPage() {
${locHeaderHtml}
${hoursHtml}
${gallerySection}
${feedSection}
</div>
<div class="tab-panel" id="tab-admins">
@@ -449,6 +517,7 @@ function renderPage() {
${locHeaderHtml}
${hoursHtml}
${gallerySection}
${feedSection}
${eventsSection}`;
}
}
@@ -1135,6 +1204,304 @@ async function sendInboxReply() {
} 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>