Files
Mario 843acea652
Some checks failed
Host-Based Deploy (Java 21 Fix) / build-and-run (push) Has been cancelled
Light- und Darkmode hinzugefügt
2026-04-28 14:07:32 +02:00

419 lines
23 KiB
HTML
Raw Permalink 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>Feed 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>
.post-card { cursor:pointer; }
.hashtag-banner { display:flex; align-items:center; gap:0.75rem; margin-bottom:1.25rem; padding:0.65rem 1rem; background:var(--color-card); border:1px solid var(--color-secondary); border-radius:8px; }
.hashtag-banner-tag { font-size:1.05rem; font-weight:700; color:var(--color-primary); }
.hashtag-banner-back { margin-left:auto; font-size:0.82rem; color:var(--color-muted); text-decoration:none; }
.hashtag-banner-back:hover { color:var(--color-primary); }
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<div class="hashtag-banner" id="hashtagBanner" style="display:none;">
<span>Posts mit </span><span class="hashtag-banner-tag" id="hashtagBannerLabel"></span>
<a class="hashtag-banner-back" href="/community/feed.html">× Zurück zum Feed</a>
</div>
<div class="tabs" id="feedTabs">
<button class="tab-btn active" data-tab="mine" onclick="switchTab('mine',this)">Mein Feed</button>
<button class="tab-btn" data-tab="public" onclick="switchTab('public',this)">Öffentlicher Feed</button>
</div>
<div class="tab-panel active" id="tab-mine">
<div class="post-compose" id="compose">
<textarea id="composeText" placeholder="Was möchtest du teilen?" rows="3"></textarea>
<div class="compose-thumbs" id="composeThumbs"></div>
<div class="umfrage-options" id="umfrageOptions" style="display:none;">
<div id="optionList"></div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:0.5rem;">
<button onclick="addOption()" style="width:auto;margin:0;padding:0.3rem 0.75rem;font-size:0.8rem;">+ Option</button>
<label class="toggle-switch"><input type="checkbox" id="multiChoice"><span class="toggle-track"></span> Mehrfachauswahl möglich</label>
</div>
</div>
<div class="compose-footer">
<label class="toggle-switch"><input type="checkbox" id="isPublic"><span class="toggle-track"></span> Öffentlich</label>
<div style="display:flex;gap:0.5rem;align-items:center;">
<button type="button" class="compose-action-btn" onclick="toggleEmojiPicker(this,'composeText')" title="Emoji">😊</button>
<label class="compose-action-btn" title="Fotos">📷
<input type="file" id="composeBildFile" accept="image/*" multiple style="display:none;" onchange="selectComposeBilder(this)">
</label>
<button type="button" id="umfrageBtn" class="compose-action-btn" onclick="toggleUmfrage(this)" title="Umfrage">📊</button>
<button onclick="submitPost()" style="width:auto;margin:0;">Veröffentlichen</button>
</div>
</div>
</div>
<div id="mineFeed"></div>
<p class="empty-hint" id="mineEmpty" style="display:none;">Noch keine Beiträge. Schreib den ersten!</p>
<div class="sentinel" id="mineSentinel"></div>
</div>
<div class="tab-panel" id="tab-public">
<div id="publicFeed"></div>
<p class="empty-hint" id="publicEmpty" style="display:none;">Noch keine öffentlichen Beiträge.</p>
<div class="sentinel" id="publicSentinel"></div>
</div>
<div id="tab-hashtag" style="display:none;">
<div id="hashtagFeed"></div>
<p class="empty-hint" id="hashtagEmpty" style="display:none;">Keine Posts mit diesem Hashtag.</p>
<div class="sentinel" id="hashtagSentinel"></div>
</div>
</div>
</div>
<!-- Lightbox (IDs geteilt mit shared.js) -->
<div class="lightbox" id="postLightbox">
<div class="lb-layout">
<button class="lb-close" onclick="closeLb()"></button>
<div class="lb-post-side" id="lbPostBody"></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>
</div>
</div>
<script src="/js/shared.js"></script>
<script src="/js/icons.js"></script>
<script src="/js/nav.js"></script>
<script src="/js/social-sidebar.js"></script>
<script src="/js/meldung.js"></script>
<script src="/js/hashtag.js"></script>
<script>
// ── State ──
let myUserId = null;
let activeHashtag = null;
const feedState = {
mine: { page:0, hasMore:true, loading:false, loaded:false },
public: { page:0, hasMore:true, loading:false, loaded:false },
hashtag: { page:0, hasMore:true, loading:false, loaded:false }
};
const feedPostCache = new Map();
const feedEditBilder = new Map();
let composeBilderArr = [];
// ── Hashtag-Modus ──
const _urlTag = new URLSearchParams(window.location.search).get('tag');
if (_urlTag) {
activeHashtag = _urlTag.replace(/^#/, '').toLowerCase();
document.getElementById('hashtagBanner').style.display = '';
document.getElementById('hashtagBannerLabel').textContent = '#' + activeHashtag;
document.getElementById('feedTabs').style.display = 'none';
document.getElementById('tab-mine').style.display = 'none';
document.getElementById('tab-public').style.display = 'none';
document.getElementById('tab-hashtag').style.display = '';
document.getElementById('compose').style.display = 'none';
}
// ── Boot ──
fetch('/login/me').then(r => r.ok ? r.json() : null).then(async user => {
if (user) {
myUserId = user.userId;
initLb(myUserId);
if (activeHashtag) {
await loadFeed('hashtag');
} else {
const raw = sessionStorage.getItem('feedOpenPost');
if (raw) {
sessionStorage.removeItem('feedOpenPost');
loadFeed('mine');
openLbWithData(JSON.parse(raw));
} else {
await loadFeed('mine');
}
}
}
}).catch(() => {});
// Hashtag-Autocomplete
if (document.readyState !== 'loading') attachHashtagAutocomplete(document.getElementById('composeText'));
else document.addEventListener('DOMContentLoaded', () => attachHashtagAutocomplete(document.getElementById('composeText')));
// ── Tabs ──
function switchTab(name, btn) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
document.getElementById('tab-' + name).classList.add('active');
localStorage.setItem('tab_feed', name);
if (!feedState[name].loaded) loadFeed(name);
}
const _savedTab = localStorage.getItem('tab_feed');
if (_savedTab) { const _b = document.querySelector(`.tab-btn[data-tab="${_savedTab}"]`); if (_b) switchTab(_savedTab, _b); }
// ── Feed laden ──
async function loadFeed(tab) {
const state = feedState[tab];
if (state.loading || !state.hasMore) return;
state.loading = true; state.loaded = true;
try {
const url = tab === 'hashtag'
? `/feed/hashtag?tag=${encodeURIComponent(activeHashtag)}&page=${state.page}&size=10`
: `${tab === 'mine' ? '/feed/mine' : '/feed/public'}?page=${state.page}&size=10`;
const res = await fetch(url);
if (!res.ok) return;
const data = await res.json();
const feedEl = document.getElementById(tab + 'Feed');
if (state.page === 0 && data.posts.length === 0) document.getElementById(tab + 'Empty').style.display = '';
data.posts.forEach(p => feedEl.insertAdjacentHTML('beforeend', renderPostCard(p, tab)));
state.hasMore = data.hasMore;
state.page++;
} finally { state.loading = false; }
}
// ── Infinite Scroll ──
const observer = new IntersectionObserver(entries => {
entries.forEach(e => {
if (!e.isIntersecting) return;
if (e.target.id === 'mineSentinel') loadFeed('mine');
if (e.target.id === 'publicSentinel') loadFeed('public');
if (e.target.id === 'hashtagSentinel') loadFeed('hashtag');
});
}, { threshold: 0.5 });
observer.observe(document.getElementById('mineSentinel'));
observer.observe(document.getElementById('publicSentinel'));
observer.observe(document.getElementById('hashtagSentinel'));
// ── Post-Card rendern ──
function renderPostCard(p, tab) {
feedPostCache.set(p.postId, { text: p.text, bilder: p.bilder || [], beitragTyp: p.beitragTyp, optionen: p.optionen || [], myVoteOptionIds: p.myVoteOptionIds || [], multiChoice: p.multiChoice, _tab: tab });
const isLocPost = p.posterType === 'LOCATION';
const authorUrl = isLocPost
? `/community/location-detail.html?id=${p.locationId || p.authorId}`
: `/community/benutzer.html?userId=${p.authorId}`;
const avatarHtml = p.authorPicture ? `<img src="data:image/png;base64,${p.authorPicture}" alt="">` : (isLocPost ? '📍' : '◉');
const privacyLabel = !p.isPublic ? ' <span style="font-size:0.7rem;color:var(--color-muted);">🔒</span>' : '';
const groupBadge = p.postType === 'GROUP' && p.gruppeId
? `<span class="gruppe-badge" onclick="event.stopPropagation()">👥 <a href="/community/gruppe.html?id=${p.gruppeId}" onclick="event.stopPropagation()">${esc(p.gruppeName)}</a></span>`
: '';
const editedLabel = p.editedAt ? ` <span style="font-size:0.75rem;color:var(--color-muted);">(bearbeitet)</span>` : '';
const umfrageHtml = buildUmfrageHtml(p.postId, p.optionen, p.myVoteOptionIds,
(postId, optionId) => `event.stopPropagation(); votePost('${postId}','${optionId}','${tab}','${p.postType}')`);
const canOwn = p.postType === 'FEED' && p.authorId === myUserId;
const editBtn = canOwn ? `<button class="post-action-btn" onclick="event.stopPropagation();startFeedEdit('${p.postId}')" title="Bearbeiten" style="color:var(--color-muted)">✏</button>` : '';
const deleteBtn = canOwn ? `<button class="post-action-btn post-delete" onclick="event.stopPropagation();deletePost('${p.postId}')">🗑</button>` : '';
const meldenBtn = p.authorId !== myUserId ? `<button class="post-action-btn" onclick="event.stopPropagation();openMeldungDialog('POST','${p.postId}')" title="Melden" style="color:var(--color-muted)">⚑</button>` : '';
const gruppeIdAttr = p.gruppeId ? ` data-gruppe-id="${p.gruppeId}"` : '';
const hasTarget = !!p.targetUrl;
const cardClick = hasTarget ? `window.location.href='${p.targetUrl}'` : `openLb('${p.postId}','${p.postType}')`;
const commentBtn = hasTarget ? '' : `<button class="post-action-btn" onclick="event.stopPropagation();openLb('${p.postId}','${p.postType}')">💬 <span id="kc-${p.postId}">${p.kommentarCount}</span></button>`;
return `<div class="post-card" id="pc-${p.postId}"${gruppeIdAttr} onclick="${cardClick}">
<div class="post-header">
<div class="post-avatar"><a href="${authorUrl}" onclick="event.stopPropagation()" style="display:contents;">${avatarHtml}</a></div>
<div>
<div class="post-author"><a href="${authorUrl}" style="color:inherit;text-decoration:none;" onclick="event.stopPropagation()">${esc(p.authorName)}</a>${privacyLabel}</div>
<div class="post-meta" id="pm-${p.postId}">${fmtDate(p.createdAt)}${editedLabel}${groupBadge}</div>
</div>
${(editBtn||deleteBtn) ? `<div style="margin-left:auto;display:flex;gap:0.25rem;">${editBtn}${deleteBtn}</div>` : ''}
</div>
<div id="pva-${p.postId}">
<div class="post-text">${renderTextWithHashtags(p.text)}</div>
<div id="pbi-${p.postId}">${bilderGrid(p.bilder)}</div>
</div>
<div id="pea-${p.postId}" style="display:none;"></div>
<div id="pum-${p.postId}">${umfrageHtml}</div>
<div class="post-actions">
<button class="post-action-btn${p.likedByMe?' active':''}" id="lk-${p.postId}" onclick="event.stopPropagation();likePost('${p.postId}','${p.postType}')">♥ <span id="lkc-${p.postId}">${p.likeCount}</span></button>
${commentBtn}${meldenBtn}
</div>
</div>`;
}
// ── Umfrage-HTML (Feed-spezifisch, da Vote-Handler Seiten-State braucht) ──
function buildUmfrageHtml(postId, optionen, myVoteOptionIds, onVoteAttrFn) {
if (!optionen || !optionen.length) return '';
const totalVotes = optionen.reduce((s, o) => s + o.stimmenCount, 0);
return '<div style="margin-top:0.5rem;">' + optionen.map(o => {
const pct = totalVotes > 0 ? Math.round(o.stimmenCount / totalVotes * 100) : 0;
const voted = myVoteOptionIds && myVoteOptionIds.includes(o.optionId);
return `<div class="umfrage-option-bar${voted?' voted':''}" onclick="${onVoteAttrFn(postId, o.optionId)}">
<div class="umfrage-bar-fill" style="width:${pct}%"></div>
<div class="umfrage-bar-content"><span>${esc(o.text)}</span><span>${pct}%</span></div>
</div>`;
}).join('') + `<div class="umfrage-total">${totalVotes} Stimme${totalVotes!==1?'n':''}</div></div>`;
}
// ── Post-Bearbeitung (Feed) ──
function startFeedEdit(postId) {
startPostEdit({ postId, prefix: 'p', data: feedPostCache.get(postId), editBilderMap: feedEditBilder,
saveFn: 'saveFeedEdit', cancelFn: 'cancelFeedEdit',
addImgFn: 'feedEditAddImg', addOptionFn: 'feedEditAddOption', rmImgFn: 'feedEditRmImg' });
}
function cancelFeedEdit(postId) { cancelPostEdit(postId, 'p', feedEditBilder); }
function feedEditRmImg(postId, idx) { feedEditBilder.get(postId).splice(idx, 1); _renderEditThumbs(feedEditBilder, postId, 'p', 'feedEditRmImg'); }
function feedEditAddImg(input, postId) {
[...input.files].forEach(f => processImageFile(f, feedEditBilder.get(postId), () => _renderEditThumbs(feedEditBilder, postId, 'p', 'feedEditRmImg')));
input.value = '';
}
function feedEditAddOption(postId) { editAddOptionRow(`peo-${postId}`); }
async function saveFeedEdit(postId) {
const cached = feedPostCache.get(postId);
await savePostEdit({ postId, prefix: 'p', endpoint: `/feed/posts/${postId}`,
isUmfrage: cached?.beitragTyp === 'UMFRAGE', editBilderMap: feedEditBilder,
onSuccess: updated => {
feedPostCache.set(postId, { ...cached, text: updated.text, bilder: updated.bilder || [], optionen: updated.optionen || [], multiChoice: updated.multiChoice });
applyPostEditDom(postId, 'p', updated,
buildUmfrageHtml(postId, updated.optionen, cached?.myVoteOptionIds || [],
(pid, oid) => `event.stopPropagation(); votePost('${pid}','${oid}','${cached?._tab}','FEED')`));
}
});
}
// ── Compose ──
function selectComposeBilder(input) {
[...input.files].forEach(f => processImageFile(f, composeBilderArr, renderComposeThumbs));
input.value = '';
}
function renderComposeThumbs() { renderBilderThumbs(composeBilderArr, 'composeThumbs', removeThumb); }
function removeThumb(idx) { composeBilderArr.splice(idx, 1); renderComposeThumbs(); }
function toggleUmfrage(btn) {
const opt = document.getElementById('umfrageOptions');
const showing = opt.style.display !== 'none';
opt.style.display = showing ? 'none' : '';
if (btn) btn.classList.toggle('active', !showing);
if (!showing && document.getElementById('optionList').children.length === 0) { addOption(); addOption(); }
}
function resetUmfrage() {
document.getElementById('umfrageOptions').style.display = 'none';
document.getElementById('optionList').innerHTML = '';
document.getElementById('umfrageBtn').classList.remove('active');
}
function addOption() {
const list = document.getElementById('optionList');
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);
}
// Drag & Drop Compose
const _compose = document.getElementById('compose');
_compose.addEventListener('dragover', e => { e.preventDefault(); if ([...e.dataTransfer.items].some(i=>i.type.startsWith('image/'))) _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,composeBilderArr,renderComposeThumbs)); });
async function submitPost() {
const text = document.getElementById('composeText').value.trim();
const hasUmfrage = document.getElementById('umfrageOptions').style.display !== 'none';
if (!text && composeBilderArr.length === 0) return;
const multiChoice = document.getElementById('multiChoice').checked;
const isPublic = document.getElementById('isPublic').checked;
let optionen = [];
if (hasUmfrage) {
optionen = Array.from(document.getElementById('optionList').querySelectorAll('input')).map(i=>i.value.trim()).filter(v=>v);
if (optionen.length < 2) { alert('Mindestens 2 Optionen erforderlich.'); return; }
}
const res = await fetch('/feed/posts', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ beitragTyp: hasUmfrage?'UMFRAGE':'TEXT', text, multiChoice, optionen, bilder:[...composeBilderArr], isPublic })
});
if (!res.ok) return;
const post = await res.json();
document.getElementById('composeText').value = '';
composeBilderArr = []; renderComposeThumbs(); resetUmfrage();
document.getElementById('multiChoice').checked = false;
document.getElementById('isPublic').checked = false;
document.getElementById('mineEmpty').style.display = 'none';
document.getElementById('mineFeed').insertAdjacentHTML('afterbegin', renderPostCard(post, 'mine'));
if (isPublic) {
document.getElementById('publicEmpty').style.display = 'none';
document.getElementById('publicFeed').insertAdjacentHTML('afterbegin', renderPostCard(post, 'public'));
}
}
// ── Like ──
async function likePost(postId, postType) {
let ep;
if (postType === 'GROUP') {
const gruppeId = document.getElementById('pc-'+postId)?.dataset?.gruppeId;
if (!gruppeId) return;
ep = `/gruppen/${gruppeId}/posts/${postId}/like`;
} else {
ep = `/feed/posts/${postId}/like`;
}
await fetch(ep, { method:'POST' });
const btn = document.getElementById('lk-'+postId);
const lc = document.getElementById('lkc-'+postId);
const was = btn.classList.contains('active');
btn.classList.toggle('active', !was);
lc.textContent = parseInt(lc.textContent) + (was ? -1 : 1);
}
// ── Vote ──
async function votePost(postId, optionId, tab, postType) {
if (postType === 'GROUP') {
const gruppeId = document.getElementById('pc-'+postId)?.dataset?.gruppeId;
if (!gruppeId) return;
await fetch(`/gruppen/${gruppeId}/posts/${postId}/vote`, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({optionId}) });
} else {
await fetch(`/feed/posts/${postId}/vote`, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({optionId}) });
}
const state = feedState[tab];
state.page = 0; state.hasMore = true; state.loaded = false;
document.getElementById(tab+'Feed').innerHTML = '';
document.getElementById(tab+'Empty').style.display = 'none';
await loadFeed(tab);
}
// ── Delete ──
async function deletePost(postId) {
if (!confirm('Post löschen?')) return;
const res = await fetch('/feed/posts/' + postId, { method:'DELETE' });
if (res.ok) document.getElementById('pc-'+postId)?.remove();
}
// ── Lightbox (openLb bleibt lokal, da Seiten-Cache nötig) ──
function openLb(postId, postType) {
const card = document.getElementById('pc-' + postId);
if (card) {
const clone = card.cloneNode(true);
clone.querySelectorAll('.post-actions').forEach(el => el.remove());
document.getElementById('lbPostBody').innerHTML = clone.innerHTML;
_lbSetupContent(postId, 'p', feedPostCache.get(postId)?.bilder);
}
loadLbComments(postId, postType);
document.getElementById('postLightbox').classList.add('open');
document.body.style.overflow = 'hidden';
}
function openLbWithData(p) {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = renderPostCard(p, 'mine');
const card = tempDiv.firstElementChild;
if (card) {
card.querySelectorAll('.post-actions').forEach(el => el.remove());
document.getElementById('lbPostBody').innerHTML = card.innerHTML;
_lbSetupContent(p.postId, 'p', p.bilder);
}
loadLbComments(p.postId, p.postType || 'FEED');
document.getElementById('postLightbox').classList.add('open');
document.body.style.overflow = 'hidden';
}
document.getElementById('postLightbox').addEventListener('click', e => { if (e.target === document.getElementById('postLightbox')) closeLb(); });
</script>
</body>
</html>