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

Binary file not shown.

View File

@@ -7,6 +7,7 @@
<title>Profil xXx Sphere</title> <title>Profil xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css"> <link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css"> <link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="/css/community.css">
<style> <style>
/* ── Profile Header ── */ /* ── Profile Header ── */
.profil-header { .profil-header {
@@ -487,50 +488,13 @@
.vorliebe-chip.bw-EHER_NICHT { border-color:#fb8c00; color:#fb8c00; } .vorliebe-chip.bw-EHER_NICHT { border-color:#fb8c00; color:#fb8c00; }
.vorliebe-chip.bw-GEHT_GAR_NICHT { border-color:#e53935; color:#e53935; } .vorliebe-chip.bw-GEHT_GAR_NICHT { border-color:#e53935; color:#e53935; }
/* ── Post cards (profile posts tab) ── */ /* ── Post-Bild-Wrap (Profil-spezifisch) ── */
.post-card { background:var(--color-card); border:1px solid var(--color-secondary); border-radius:10px; padding:1rem; margin-bottom:0.9rem; }
.post-header { display:flex; align-items:center; gap:0.7rem; margin-bottom:0.6rem; }
.post-avatar { width:36px; height:36px; border-radius:50%; background:var(--color-secondary); display:flex; align-items:center; justify-content:center; font-size:0.95rem; flex-shrink:0; overflow:hidden; }
.post-avatar img { width:100%; height:100%; object-fit:cover; }
.post-author { font-weight:600; font-size:0.9rem; }
.post-date { font-size:0.75rem; color:var(--color-muted); margin-left:auto; }
.post-text { font-size:0.95rem; line-height:1.5; white-space:pre-wrap; word-break:break-word; }
.post-bild { width:100%; max-height:360px; object-fit:contain; border-radius:6px; margin-top:0.5rem; display:block; transition:opacity 0.2s; }
.post-bild-wrap { position:relative; cursor:pointer; display:block; } .post-bild-wrap { position:relative; cursor:pointer; display:block; }
.post-bild-wrap:hover .post-bild { opacity:0.82; } .post-bild-wrap:hover .post-bild { opacity:0.82; }
.post-bild-hover-icon { position:absolute; inset:0; display:flex; align-items:center; justify-content:center; opacity:0; transition:opacity 0.2s; pointer-events:none; font-size:1.6rem; } .post-bild-hover-icon { position:absolute; inset:0; display:flex; align-items:center; justify-content:center; opacity:0; transition:opacity 0.2s; pointer-events:none; font-size:1.6rem; }
.post-bild-wrap:hover .post-bild-hover-icon { opacity:1; } .post-bild-wrap:hover .post-bild-hover-icon { opacity:1; }
.post-actions { display:flex; gap:1rem; margin-top:0.75rem; align-items:center; flex-wrap:wrap; } /* ── Bild-Navigation in Lightbox ── */
.post-action-btn { background:none; border:none; color:var(--color-muted); cursor:pointer; font-size:0.85rem; padding:0; display:flex; align-items:center; gap:0.3rem; margin:0; width:auto; }
.post-action-btn:hover { color:var(--color-primary); background:none; }
.post-action-btn.active { color:var(--color-primary); }
.post-delete { margin-left:auto; }
.post-delete:hover { color:#c0392b !important; }
.umfrage-option-bar { margin:0.3rem 0; cursor:pointer; border-radius:6px; overflow:hidden; border:1px solid var(--color-secondary); position:relative; transition:border-color 0.15s; }
.umfrage-option-bar:hover { border-color:var(--color-primary); }
.umfrage-option-bar.voted { border-color:var(--color-primary); }
.umfrage-bar-fill { position:absolute; inset:0; background:rgba(var(--color-primary-rgb,180,0,60),0.15); transition:width 0.4s; }
.umfrage-bar-content { position:relative; display:flex; justify-content:space-between; padding:0.45rem 0.75rem; font-size:0.88rem; }
.umfrage-total { font-size:0.78rem; color:var(--color-muted); margin-top:0.3rem; }
/* ── Post / Bild Lightbox ── */
.lightbox { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.88); z-index:400; align-items:center; justify-content:center; }
.lightbox.open { display:flex; }
.lb-layout { display:flex; max-width:min(1340px, calc(100vw - 2rem)); width:95vw; height:min(90vh, 1100px); background:var(--color-card); border-radius:12px; overflow:hidden; position:relative; }
.lb-post-side .post-bild { max-height:1024px; }
.lb-close { position:absolute; top:0.6rem; right:0.6rem; background:rgba(0,0,0,0.55); border:none; color:#fff; font-size:1.1rem; width:2rem; height:2rem; border-radius:50%; cursor:pointer; z-index:10; display:flex; align-items:center; justify-content:center; padding:0; margin:0; }
.lb-post-side { flex:1; overflow-y:auto; padding:1.25rem; border-right:1px solid var(--color-secondary); min-width:0; }
.lb-comments-panel { width:300px; flex-shrink:0; display:flex; flex-direction:column; }
.lb-comments-header { font-size:0.78rem; font-weight:600; color:var(--color-muted); text-transform:uppercase; letter-spacing:0.06em; padding:0.7rem 1rem; border-bottom:1px solid var(--color-secondary); flex-shrink:0; }
.lb-comments-list { flex:1; overflow-y:auto; padding:0.75rem; }
.lb-comment-compose { padding:0.75rem; border-top:1px solid var(--color-secondary); display:flex; flex-direction:column; gap:0.5rem; flex-shrink:0; }
.lb-comment-compose textarea { width:100%; font-size:0.85rem; padding:0.35rem 0.6rem; resize:none; background:var(--color-secondary); border:1px solid var(--color-secondary); border-radius:6px; color:var(--color-text); font-family:inherit; outline:none; transition:border-color 0.2s; box-sizing:border-box; }
.lb-comment-compose textarea:focus { border-color:var(--color-primary); }
.lb-comment-compose-actions { display:flex; gap:0.5rem; justify-content:flex-end; }
.lb-comment-compose button { width:auto; margin:0; padding:0.35rem 0.75rem; font-size:0.8rem; }
.lb-img-nav { display:flex; gap:0.75rem; align-items:center; justify-content:center; margin-top:0.75rem; flex-wrap:wrap; } .lb-img-nav { display:flex; gap:0.75rem; align-items:center; justify-content:center; margin-top:0.75rem; flex-wrap:wrap; }
.compose-action-btn { background:none; border:1px solid var(--color-secondary); color:var(--color-muted); border-radius:6px; padding:0.35rem 0.6rem; font-size:0.95rem; cursor:pointer; margin:0; width:auto; }
@media (max-width:650px) { .lb-layout { flex-direction:column; height:95vh; } .lb-post-side { border-right:none; border-bottom:1px solid var(--color-secondary); max-height:55vh; } .lb-comments-panel { width:100%; } }
</style> </style>
</head> </head>
<body class="app"> <body class="app">
@@ -652,8 +616,9 @@
<script src="/js/shared.js"></script> <script src="/js/shared.js"></script>
<script src="/js/image-viewer.js"></script> <script src="/js/image-viewer.js"></script>
<script src="/js/icons.js"></script> <script src="/js/icons.js"></script>
<script src="/js/nav.js"></script> <script src="/js/nav.js"></script>
<script src="/js/social-sidebar.js"></script> <script src="/js/social-sidebar.js"></script>
<script src="/js/hashtag.js"></script>
<script src="/js/meldung.js"></script> <script src="/js/meldung.js"></script>
<script> <script>
// ── State ── // ── State ──
@@ -680,6 +645,9 @@
let activeLbPostId = null; let activeLbPostId = null;
let activeLbMode = null; // 'post' | 'image' let activeLbMode = null; // 'post' | 'image'
let activeLbImageIdx = null; let activeLbImageIdx = null;
const profilPostBilder = new Map(); // postId → bilder[] für Lightbox-Carousel
const profilPostCache = {}; // postId → post-Objekt für Edit
const profilEditBilder = new Map(); // postId → bilder[] während Bearbeitung
// ── Label maps ── // ── Label maps ──
const GESCHLECHT_LABEL = { WEIBLICH: 'weiblich', DIVERS: 'divers', MAENNLICH: 'männlich' }; const GESCHLECHT_LABEL = { WEIBLICH: 'weiblich', DIVERS: 'divers', MAENNLICH: 'männlich' };
@@ -719,6 +687,7 @@
} }
myUserId = me ? me.userId : null; myUserId = me ? me.userId : null;
initLb(myUserId);
isOwnProfile = !previewMode && me && me.userId === profile.userId; isOwnProfile = !previewMode && me && me.userId === profile.userId;
profileData = profile; profileData = profile;
allImages = images; allImages = images;
@@ -1248,6 +1217,7 @@
} }
function renderLbImageBody() { function renderLbImageBody() {
document.querySelector('#postLightbox .lb-layout')?.classList.remove('lb-text-only');
const img = allImages[activeLbImageIdx]; const img = allImages[activeLbImageIdx];
const total = allImages.length; const total = allImages.length;
const prevDisabled = activeLbImageIdx === 0 ? 'disabled' : ''; const prevDisabled = activeLbImageIdx === 0 ? 'disabled' : '';
@@ -1589,42 +1559,48 @@
const avatarHtml = p.authorPicture const avatarHtml = p.authorPicture
? `<img src="data:image/png;base64,${p.authorPicture}" alt="">` ? `<img src="data:image/png;base64,${p.authorPicture}" alt="">`
: '◉'; : '◉';
const bildRaw = bilderCarousel(p.bilder); profilPostCache[p.postId] = p;
const bildHtml = bildRaw profilPostBilder.set(p.postId, p.bilder || []);
? `<div class="post-bild-wrap" data-post-id="${p.postId}">${bildRaw}</div>` const bildHtml = bilderGrid(p.bilder);
: '';
const privacyLabel = p.isPublic ? '' : '<span style="font-size:0.72rem;color:var(--color-muted);margin-left:0.4rem;">🔒 privat</span>'; const privacyLabel = p.isPublic ? '' : '<span style="font-size:0.72rem;color:var(--color-muted);margin-left:0.4rem;">🔒 privat</span>';
const editedLabel = p.editedAt ? ' <span class="edited-label" style="font-size:0.75rem;color:var(--color-muted);">(bearbeitet)</span>' : '';
let umfrageHtml = ''; let umfrageHtml = '';
if (p.beitragTyp === 'UMFRAGE' && p.optionen && p.optionen.length > 0) { if (p.beitragTyp === 'UMFRAGE' && p.optionen && p.optionen.length > 0) {
const totalVotes = p.optionen.reduce((s, o) => s + o.stimmenCount, 0); const totalVotes = p.optionen.reduce((s, o) => s + o.stimmenCount, 0);
umfrageHtml = '<div style="margin-top:0.5rem;">' + p.optionen.map(o => { umfrageHtml = p.optionen.map(o => {
const pct = totalVotes > 0 ? Math.round(o.stimmenCount / totalVotes * 100) : 0; const pct = totalVotes > 0 ? Math.round(o.stimmenCount / totalVotes * 100) : 0;
const voted = p.myVoteOptionIds && p.myVoteOptionIds.includes(o.optionId); const voted = p.myVoteOptionIds && p.myVoteOptionIds.includes(o.optionId);
return `<div class="umfrage-option-bar${voted ? ' voted' : ''}" onclick="voteProfilPost('${p.postId}','${o.optionId}')"> return `<div class="umfrage-option-bar${voted ? ' voted' : ''}" onclick="voteProfilPost('${p.postId}','${o.optionId}')">
<div class="umfrage-bar-fill" style="width:${pct}%"></div> <div class="umfrage-bar-fill" style="width:${pct}%"></div>
<div class="umfrage-bar-content"><span>${esc(o.text)}</span><span>${pct}%</span></div> <div class="umfrage-bar-content"><span>${esc(o.text)}</span><span>${pct}%</span></div>
</div>`; </div>`;
}).join('') + `<div class="umfrage-total">${totalVotes} Stimme${totalVotes !== 1 ? 'n' : ''}</div></div>`; }).join('') + `<div class="umfrage-total">${totalVotes} Stimme${totalVotes !== 1 ? 'n' : ''}</div>`;
} }
const canDelete = p.authorId === myUserId; const isOwn = p.authorId === myUserId;
const deleteBtn = canDelete const rightBtns = isOwn
? `<button class="post-action-btn post-delete" onclick="deleteProfilPost('${p.postId}')">🗑</button>` ? `<div style="margin-left:auto;display:flex;gap:0.25rem;">
<button class="post-action-btn" onclick="event.stopPropagation();startProfilEdit('${p.postId}')" title="Bearbeiten" style="color:var(--color-muted)">✏</button>
<button class="post-action-btn post-delete" onclick="event.stopPropagation();deleteProfilPost('${p.postId}')">🗑</button>
</div>`
: ''; : '';
return `<div class="post-card" id="pp-${p.postId}"> return `<div class="post-card" id="pp-${p.postId}">
<div class="post-header"> <div class="post-header">
<div class="post-avatar">${avatarHtml}</div> <div class="post-avatar"><a href="/community/benutzer.html?userId=${p.authorId}" onclick="event.stopPropagation()" style="display:contents;">${avatarHtml}</a></div>
<div> <div>
<div class="post-author">${esc(p.authorName)}${privacyLabel}</div> <div class="post-author"><a href="/community/benutzer.html?userId=${p.authorId}" style="color:inherit;text-decoration:none;" onclick="event.stopPropagation()">${esc(p.authorName)}</a>${privacyLabel}</div>
<div class="post-date">${fmtDate(p.createdAt)}</div> <div class="post-date" id="ppm-${p.postId}">${fmtDate(p.createdAt)}${editedLabel}</div>
</div> </div>
${deleteBtn} ${rightBtns}
</div> </div>
<div id="ppva-${p.postId}">
<div class="post-text">${esc(p.text)}</div> <div class="post-text">${esc(p.text)}</div>
${bildHtml} <div id="ppbi-${p.postId}" class="post-bild-wrap" data-post-id="${p.postId}">${bildHtml}</div>
${umfrageHtml} </div>
<div id="ppea-${p.postId}" style="display:none;"></div>
<div id="ppum-${p.postId}">${umfrageHtml}</div>
<div class="post-actions"> <div class="post-actions">
<button class="post-action-btn${p.likedByMe ? ' active' : ''}" id="pp-like-${p.postId}" onclick="likeProfilPost('${p.postId}')"> <button class="post-action-btn${p.likedByMe ? ' active' : ''}" id="pp-like-${p.postId}" onclick="likeProfilPost('${p.postId}')">
♥ <span id="pp-lc-${p.postId}">${p.likeCount}</span> ♥ <span id="pp-lc-${p.postId}">${p.likeCount}</span>
@@ -1665,6 +1641,73 @@
if (res.ok) document.getElementById('pp-' + postId)?.remove(); if (res.ok) document.getElementById('pp-' + postId)?.remove();
} }
// ── Profil-Post-Bearbeitung ──
function startProfilEdit(postId) {
const post = profilPostCache[postId];
if (!post) return;
startPostEdit({
postId,
prefix: 'pp',
data: post,
editBilderMap: profilEditBilder,
saveFn: 'saveProfilEdit',
cancelFn: 'cancelProfilEdit',
addImgFn: 'profilEditAddImg',
addOptionFn: 'profilEditAddOption',
rmImgFn: 'profilEditRmImg'
});
}
function cancelProfilEdit(postId) {
cancelPostEdit(postId, 'pp', profilEditBilder);
}
function profilEditRmImg(postId, idx) {
profilEditBilder.get(postId)?.splice(idx, 1);
_renderEditThumbs(profilEditBilder, postId, 'pp', 'profilEditRmImg');
}
function profilEditAddImg(input, postId) {
const arr = profilEditBilder.get(postId);
if (!arr) return;
[...input.files].forEach(f => processImageFile(f, arr, () => {
_renderEditThumbs(profilEditBilder, postId, 'pp', 'profilEditRmImg');
}));
input.value = '';
}
function profilEditAddOption(postId) {
editAddOptionRow('ppeo-' + postId);
}
async function saveProfilEdit(postId) {
const post = profilPostCache[postId];
await savePostEdit({
postId,
prefix: 'pp',
endpoint: '/feed/posts/' + postId,
isUmfrage: post?.beitragTyp === 'UMFRAGE',
editBilderMap: profilEditBilder,
onSuccess: updated => {
profilPostCache[postId] = { ...profilPostCache[postId], text: updated.text, bilder: updated.bilder || [], optionen: updated.optionen || [] };
profilPostBilder.set(postId, updated.bilder || []);
let umfrageHtml = '';
if (post?.beitragTyp === 'UMFRAGE' && updated.optionen) {
const totalVotes = updated.optionen.reduce((s, o) => s + o.stimmenCount, 0);
umfrageHtml = updated.optionen.map(o => {
const pct = totalVotes > 0 ? Math.round(o.stimmenCount / totalVotes * 100) : 0;
return `<div class="umfrage-option-bar" onclick="voteProfilPost('${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>`;
}
applyPostEditDom(postId, 'pp', updated, umfrageHtml);
}
});
}
// ── Lightbox (Post + Bild) ── // ── Lightbox (Post + Bild) ──
function openPostLb(postId) { function openPostLb(postId) {
activeLbMode = 'post'; activeLbMode = 'post';
@@ -1674,7 +1717,9 @@
if (card) { if (card) {
const clone = card.cloneNode(true); const clone = card.cloneNode(true);
clone.querySelectorAll('.post-actions').forEach(el => el.remove()); clone.querySelectorAll('.post-actions').forEach(el => el.remove());
clone.querySelectorAll('[id^="ppea-"]').forEach(el => el.remove());
document.getElementById('lbPostBody').innerHTML = clone.innerHTML; document.getElementById('lbPostBody').innerHTML = clone.innerHTML;
_lbSetupContent(postId, 'pp', profilPostBilder.get(postId) || []);
} }
loadLbComments(); loadLbComments();
document.getElementById('postLightbox').classList.add('open'); document.getElementById('postLightbox').classList.add('open');
@@ -1728,7 +1773,6 @@
if (e.target === document.getElementById('postLightbox')) closeLb(); if (e.target === document.getElementById('postLightbox')) closeLb();
}); });
document.addEventListener('keydown', e => { document.addEventListener('keydown', e => {
if (e.key === 'Escape' && document.getElementById('postLightbox').classList.contains('open')) closeLb();
if (activeLbMode === 'image') { if (activeLbMode === 'image') {
if (e.key === 'ArrowLeft') lbGalleryNav(-1); if (e.key === 'ArrowLeft') lbGalleryNav(-1);
if (e.key === 'ArrowRight') lbGalleryNav(1); if (e.key === 'ArrowRight') lbGalleryNav(1);

View File

@@ -7,135 +7,49 @@
<title>Feed xXx Sphere</title> <title>Feed xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css"> <link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css"> <link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="/css/community.css">
<style> <style>
.tabs { display:flex; gap:0; margin-bottom:1.5rem; border-bottom:1px solid var(--color-secondary); } .post-card { cursor:pointer; }
.tab-btn { background:none; border:none; border-bottom:3px solid transparent; border-radius:0; padding:0.6rem 1.25rem; font-size:0.95rem; font-weight:600; color:var(--color-muted); cursor:pointer; margin-bottom:-1px; transition:color 0.15s,border-color 0.15s; }
.tab-btn:hover { color:var(--color-text); background:none; }
.tab-btn.active { color:var(--color-primary); border-bottom-color:var(--color-primary); }
.tab-panel { display:none; }
.tab-panel.active { display:block; }
.post-compose { background:var(--color-card); border:1px solid var(--color-secondary); border-radius:10px; padding:1rem; margin-bottom:1rem; transition:border-color 0.15s; }
.post-compose.drag-over { border-color:var(--color-primary); background:rgba(var(--color-primary-rgb,180,0,60),0.06); }
.compose-type { display:flex; gap:1.5rem; margin-bottom:0.75rem; }
.compose-type label { display:flex; align-items:center; gap:0.4rem; font-size:0.9rem; cursor:pointer; }
.post-compose textarea { width:100%; padding:0.6rem 0.85rem; border:1px solid var(--color-secondary); border-radius:6px; background:var(--color-secondary); color:var(--color-text); font-size:0.95rem; outline:none; transition:border-color 0.2s; resize:vertical; min-height:70px; box-sizing:border-box; }
.post-compose textarea:focus { border-color:var(--color-primary); }
.compose-thumbs { display:none; flex-wrap:wrap; gap:0.5rem; margin-top:0.5rem; }
.compose-thumb { position:relative; width:64px; height:64px; flex-shrink:0; }
.compose-thumb img { width:64px; height:64px; object-fit:cover; border-radius:6px; display:block; }
.compose-thumb-remove { position:absolute; top:-5px; right:-5px; background:rgba(0,0,0,0.7); border:none; color:#fff; width:18px; height:18px; border-radius:50%; font-size:0.65rem; cursor:pointer; display:flex; align-items:center; justify-content:center; padding:0; margin:0; width:auto; line-height:1; }
.umfrage-options { margin-top:0.5rem; }
.umfrage-option-row { display:flex; gap:0.5rem; margin-bottom:0.4rem; }
.umfrage-option-row input { flex:1; }
.umfrage-option-row button { width:auto; margin:0; padding:0.3rem 0.6rem; font-size:0.8rem; }
.compose-footer { display:flex; justify-content:space-between; align-items:center; margin-top:0.75rem; flex-wrap:wrap; gap:0.5rem; }
.multi-toggle { font-size:0.85rem; display:flex; align-items:center; gap:0.4rem; }
.privacy-toggle { font-size:0.85rem; display:flex; align-items:center; gap:0.4rem; }
.compose-action-btn { background:none; border:1px solid var(--color-secondary); color:var(--color-muted); border-radius:6px; padding:0.35rem 0.6rem; font-size:0.95rem; cursor:pointer; margin:0; width:auto; transition:border-color 0.15s,color 0.15s; }
.compose-action-btn:hover { border-color:var(--color-primary); color:var(--color-primary); background:none; }
label.compose-action-btn { display:inline-flex; align-items:center; }
.post-card { background:var(--color-card); border:1px solid var(--color-secondary); border-radius:10px; padding:1rem; margin-bottom:0.9rem; }
.post-header { display:flex; align-items:center; gap:0.7rem; margin-bottom:0.6rem; }
.post-avatar { width:36px; height:36px; border-radius:50%; background:var(--color-secondary); display:flex; align-items:center; justify-content:center; font-size:0.95rem; flex-shrink:0; overflow:hidden; }
.post-avatar img { width:100%; height:100%; object-fit:cover; }
.post-author { font-weight:600; font-size:0.9rem; }
.post-meta { font-size:0.75rem; color:var(--color-muted); }
.post-date { font-size:0.75rem; color:var(--color-muted); margin-left:auto; }
.post-text { font-size:0.95rem; line-height:1.5; white-space:pre-wrap; word-break:break-word; }
.post-bild { width:100%; max-height:400px; object-fit:contain; border-radius:6px; margin-top:0.5rem; display:block; }
.post-actions { display:flex; gap:1rem; margin-top:0.75rem; align-items:center; flex-wrap:wrap; }
.post-action-btn { background:none; border:none; color:var(--color-muted); cursor:pointer; font-size:0.85rem; padding:0; display:flex; align-items:center; gap:0.3rem; margin:0; width:auto; }
.post-action-btn:hover { color:var(--color-primary); background:none; }
.post-action-btn.active { color:var(--color-primary); }
.post-delete { margin-left:auto; }
.post-delete:hover { color:#c0392b !important; }
/* Carousel Stile kommen aus shared.js */
.umfrage-option-bar { margin:0.3rem 0; cursor:pointer; border-radius:6px; overflow:hidden; border:1px solid var(--color-secondary); position:relative; transition:border-color 0.15s; }
.umfrage-option-bar:hover { border-color:var(--color-primary); }
.umfrage-option-bar.voted { border-color:var(--color-primary); }
.umfrage-bar-fill { position:absolute; inset:0; background:rgba(var(--color-primary-rgb,180,0,60),0.15); transition:width 0.4s; }
.umfrage-bar-content { position:relative; display:flex; justify-content:space-between; padding:0.45rem 0.75rem; font-size:0.88rem; }
.umfrage-total { font-size:0.78rem; color:var(--color-muted); margin-top:0.3rem; }
.gruppe-badge { display:inline-flex; align-items:center; gap:0.3rem; font-size:0.75rem; color:var(--color-muted); background:var(--color-secondary); border-radius:4px; padding:0.15rem 0.45rem; margin-top:0.1rem; }
.gruppe-badge a { color:inherit; text-decoration:none; }
.gruppe-badge a:hover { color:var(--color-primary); }
.empty-hint { color:var(--color-muted); font-size:0.9rem; margin-top:0.5rem; }
.sentinel { height:1px; }
.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 { 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-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 { margin-left:auto; font-size:0.82rem; color:var(--color-muted); text-decoration:none; }
.hashtag-banner-back:hover { color:var(--color-primary); } .hashtag-banner-back:hover { color:var(--color-primary); }
/* Lightbox */
.lightbox { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.88); z-index:300; align-items:center; justify-content:center; }
.lightbox.open { display:flex; }
.lb-layout { display:flex; max-width:min(1340px, calc(100vw - 2rem)); width:95vw; height:min(90vh, 1100px); background:var(--color-card); border-radius:12px; overflow:hidden; position:relative; }
.lb-post-side .post-bild { max-height:1024px; }
.lb-close { position:absolute; top:0.6rem; right:0.6rem; background:rgba(0,0,0,0.55); border:none; color:#fff; font-size:1.1rem; width:2rem; height:2rem; border-radius:50%; cursor:pointer; z-index:10; display:flex; align-items:center; justify-content:center; padding:0; margin:0; }
.lb-post-side { flex:1; overflow-y:auto; padding:1.25rem; border-right:1px solid var(--color-secondary); min-width:0; }
.lb-comments-panel { width:300px; flex-shrink:0; display:flex; flex-direction:column; }
.lb-comments-header { font-size:0.78rem; font-weight:600; color:var(--color-muted); text-transform:uppercase; letter-spacing:0.06em; padding:0.7rem 1rem; border-bottom:1px solid var(--color-secondary); flex-shrink:0; }
.lb-comments-list { flex:1; overflow-y:auto; padding:0.75rem; }
.lb-comment-compose { padding:0.75rem; border-top:1px solid var(--color-secondary); display:flex; flex-direction:column; gap:0.5rem; flex-shrink:0; }
.lb-comment-compose textarea { width:100%; font-size:0.85rem; padding:0.35rem 0.6rem; resize:none; background:var(--color-secondary); border:1px solid var(--color-secondary); border-radius:6px; color:var(--color-text); font-family:inherit; outline:none; transition:border-color 0.2s; box-sizing:border-box; }
.lb-comment-compose textarea:focus { border-color:var(--color-primary); }
.lb-comment-compose-actions { display:flex; gap:0.5rem; justify-content:flex-end; }
.lb-comment-compose button { width:auto; margin:0; padding:0.35rem 0.75rem; font-size:0.8rem; }
@media (max-width:650px) { .lb-layout { flex-direction:column; height:95vh; } .lb-post-side { border-right:none; border-bottom:1px solid var(--color-secondary); max-height:55vh; } .lb-comments-panel { width:100%; } }
/* Comment + Like-Stile kommen aus shared.js */
</style> </style>
</head> </head>
<body class="app"> <body class="app">
<div class="main"> <div class="main">
<div class="content"> <div class="content">
<!-- Hashtag-Banner (nur sichtbar wenn ?tag=… gesetzt) -->
<div class="hashtag-banner" id="hashtagBanner" style="display:none;"> <div class="hashtag-banner" id="hashtagBanner" style="display:none;">
<span>Posts mit </span><span class="hashtag-banner-tag" id="hashtagBannerLabel"></span> <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> <a class="hashtag-banner-back" href="/community/feed.html">× Zurück zum Feed</a>
</div> </div>
<div class="tabs" id="feedTabs"> <div class="tabs" id="feedTabs">
<button class="tab-btn active" id="tabMine" data-tab="mine" onclick="switchTab('mine', this)">Mein Feed</button> <button class="tab-btn active" data-tab="mine" onclick="switchTab('mine',this)">Mein Feed</button>
<button class="tab-btn" id="tabPublic" data-tab="public" onclick="switchTab('public', this)">Öffentlicher Feed</button> <button class="tab-btn" data-tab="public" onclick="switchTab('public',this)">Öffentlicher Feed</button>
</div> </div>
<!-- Mein Feed -->
<div class="tab-panel active" id="tab-mine"> <div class="tab-panel active" id="tab-mine">
<div class="post-compose" id="compose"> <div class="post-compose" id="compose">
<div class="compose-type">
<label><input type="radio" name="beitragTyp" value="TEXT" checked onchange="toggleUmfrage()"> Text</label>
<label><input type="radio" name="beitragTyp" value="UMFRAGE" onchange="toggleUmfrage()"> Umfrage</label>
</div>
<textarea id="composeText" placeholder="Was möchtest du teilen?" rows="3"></textarea> <textarea id="composeText" placeholder="Was möchtest du teilen?" rows="3"></textarea>
<div class="compose-thumbs" id="composeThumbs"></div> <div class="compose-thumbs" id="composeThumbs"></div>
<div class="umfrage-options" id="umfrageOptions" style="display:none;"> <div class="umfrage-options" id="umfrageOptions" style="display:none;">
<div id="optionList"></div> <div id="optionList"></div>
<button onclick="addOption()" style="width:auto; margin:0; padding:0.3rem 0.75rem; font-size:0.8rem; margin-top:0.4rem;">+ Option</button> <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="multi-toggle"><input type="checkbox" id="multiChoice"> Mehrfachauswahl möglich</label>
</div>
</div> </div>
<div class="compose-footer"> <div class="compose-footer">
<div style="display:flex;gap:1rem;align-items:center;flex-wrap:wrap;"> <label class="privacy-toggle"><input type="checkbox" id="isPublic"> Öffentlich</label>
<label class="multi-toggle" id="multiChoiceRow" style="display:none;">
<input type="checkbox" id="multiChoice"> Multi-Choice
</label>
<label class="privacy-toggle">
<input type="checkbox" id="isPublic"> Öffentlich
</label>
</div>
<div style="display:flex;gap:0.5rem;align-items:center;"> <div style="display:flex;gap:0.5rem;align-items:center;">
<button type="button" class="compose-action-btn" onclick="toggleEmojiPicker(this,'composeText')" title="Emoji einfügen">😊</button> <button type="button" class="compose-action-btn" onclick="toggleEmojiPicker(this,'composeText')" title="Emoji">😊</button>
<label class="compose-action-btn" title="Fotos hinzufügen">📷 <label class="compose-action-btn" title="Fotos">📷
<input type="file" id="composeBildFile" accept="image/*" multiple style="display:none;" onchange="selectComposeBilder(this)"> <input type="file" id="composeBildFile" accept="image/*" multiple style="display:none;" onchange="selectComposeBilder(this)">
</label> </label>
<button onclick="submitPost()" style="width:auto; margin:0;">Veröffentlichen</button> <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>
</div> </div>
@@ -144,24 +58,21 @@
<div class="sentinel" id="mineSentinel"></div> <div class="sentinel" id="mineSentinel"></div>
</div> </div>
<!-- Öffentlicher Feed -->
<div class="tab-panel" id="tab-public"> <div class="tab-panel" id="tab-public">
<div id="publicFeed"></div> <div id="publicFeed"></div>
<p class="empty-hint" id="publicEmpty" style="display:none;">Noch keine öffentlichen Beiträge.</p> <p class="empty-hint" id="publicEmpty" style="display:none;">Noch keine öffentlichen Beiträge.</p>
<div class="sentinel" id="publicSentinel"></div> <div class="sentinel" id="publicSentinel"></div>
</div> </div>
<!-- Hashtag-Feed (wird angezeigt wenn ?tag=… gesetzt) -->
<div id="tab-hashtag" style="display:none;"> <div id="tab-hashtag" style="display:none;">
<div id="hashtagFeed"></div> <div id="hashtagFeed"></div>
<p class="empty-hint" id="hashtagEmpty" style="display:none;">Keine Posts mit diesem Hashtag.</p> <p class="empty-hint" id="hashtagEmpty" style="display:none;">Keine Posts mit diesem Hashtag.</p>
<div class="sentinel" id="hashtagSentinel"></div> <div class="sentinel" id="hashtagSentinel"></div>
</div> </div>
</div> </div>
</div> </div>
<!-- Post Lightbox --> <!-- Lightbox (IDs geteilt mit shared.js) -->
<div class="lightbox" id="postLightbox"> <div class="lightbox" id="postLightbox">
<div class="lb-layout"> <div class="lb-layout">
<button class="lb-close" onclick="closeLb()"></button> <button class="lb-close" onclick="closeLb()"></button>
@@ -190,9 +101,7 @@
<script> <script>
// ── State ── // ── State ──
let myUserId = null; let myUserId = null;
let activeLbPostId = null; let activeHashtag = null;
let activeLbPostType = null;
let activeHashtag = null; // set when ?tag=... is in URL
const feedState = { const feedState = {
mine: { page:0, hasMore:true, loading:false, loaded:false }, mine: { page:0, hasMore:true, loading:false, loaded:false },
@@ -200,9 +109,11 @@
hashtag: { 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 = []; let composeBilderArr = [];
// ── Hashtag-Modus prüfen ── // ── Hashtag-Modus ──
const _urlTag = new URLSearchParams(window.location.search).get('tag'); const _urlTag = new URLSearchParams(window.location.search).get('tag');
if (_urlTag) { if (_urlTag) {
activeHashtag = _urlTag.replace(/^#/, '').toLowerCase(); activeHashtag = _urlTag.replace(/^#/, '').toLowerCase();
@@ -219,6 +130,7 @@
fetch('/login/me').then(r => r.ok ? r.json() : null).then(async user => { fetch('/login/me').then(r => r.ok ? r.json() : null).then(async user => {
if (user) { if (user) {
myUserId = user.userId; myUserId = user.userId;
initLb(myUserId);
if (activeHashtag) { if (activeHashtag) {
await loadFeed('hashtag'); await loadFeed('hashtag');
} else { } else {
@@ -234,18 +146,11 @@
} }
}).catch(() => {}); }).catch(() => {});
// ── Autocomplete für Compose ── // Hashtag-Autocomplete
document.addEventListener('DOMContentLoaded', () => { if (document.readyState !== 'loading') attachHashtagAutocomplete(document.getElementById('composeText'));
const ta = document.getElementById('composeText'); else document.addEventListener('DOMContentLoaded', () => attachHashtagAutocomplete(document.getElementById('composeText')));
if (ta) attachHashtagAutocomplete(ta);
});
// Fallback falls DOMContentLoaded bereits gefeuert
if (document.readyState !== 'loading') {
const ta = document.getElementById('composeText');
if (ta) attachHashtagAutocomplete(ta);
}
// ── Tab switching ── // ── Tabs ──
function switchTab(name, btn) { function switchTab(name, btn) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active'); btn.classList.add('active');
@@ -254,39 +159,27 @@
localStorage.setItem('tab_feed', name); localStorage.setItem('tab_feed', name);
if (!feedState[name].loaded) loadFeed(name); if (!feedState[name].loaded) loadFeed(name);
} }
const _savedFeedTab = localStorage.getItem('tab_feed'); const _savedTab = localStorage.getItem('tab_feed');
if (_savedFeedTab) { if (_savedTab) { const _b = document.querySelector(`.tab-btn[data-tab="${_savedTab}"]`); if (_b) switchTab(_savedTab, _b); }
const _btn = document.querySelector(`.tab-btn[data-tab="${_savedFeedTab}"]`);
if (_btn) switchTab(_savedFeedTab, _btn);
}
// ── Feed loading ── // ── Feed laden ──
async function loadFeed(tab) { async function loadFeed(tab) {
const state = feedState[tab]; const state = feedState[tab];
if (state.loading || !state.hasMore) return; if (state.loading || !state.hasMore) return;
state.loading = true; state.loading = true; state.loaded = true;
state.loaded = true;
try { try {
let url; const url = tab === 'hashtag'
if (tab === 'hashtag') { ? `/feed/hashtag?tag=${encodeURIComponent(activeHashtag)}&page=${state.page}&size=10`
url = `/feed/hashtag?tag=${encodeURIComponent(activeHashtag)}&page=${state.page}&size=10`; : `${tab === 'mine' ? '/feed/mine' : '/feed/public'}?page=${state.page}&size=10`;
} else {
const base = tab === 'mine' ? '/feed/mine' : '/feed/public';
url = `${base}?page=${state.page}&size=10`;
}
const res = await fetch(url); const res = await fetch(url);
if (!res.ok) return; if (!res.ok) return;
const data = await res.json(); const data = await res.json();
const feedEl = document.getElementById(tab + 'Feed'); const feedEl = document.getElementById(tab + 'Feed');
if (state.page === 0 && data.posts.length === 0) { if (state.page === 0 && data.posts.length === 0) document.getElementById(tab + 'Empty').style.display = '';
document.getElementById(tab + 'Empty').style.display = '';
}
data.posts.forEach(p => feedEl.insertAdjacentHTML('beforeend', renderPostCard(p, tab))); data.posts.forEach(p => feedEl.insertAdjacentHTML('beforeend', renderPostCard(p, tab)));
state.hasMore = data.hasMore; state.hasMore = data.hasMore;
state.page++; state.page++;
} finally { } finally { state.loading = false; }
state.loading = false;
}
} }
// ── Infinite Scroll ── // ── Infinite Scroll ──
@@ -302,188 +195,148 @@
observer.observe(document.getElementById('publicSentinel')); observer.observe(document.getElementById('publicSentinel'));
observer.observe(document.getElementById('hashtagSentinel')); observer.observe(document.getElementById('hashtagSentinel'));
// bilderCarousel und carNav kommen aus shared.js // ── Post-Card rendern ──
// ── Render post card ──
function renderPostCard(p, tab) { function renderPostCard(p, tab) {
const avatarHtml = p.authorPicture feedPostCache.set(p.postId, { text: p.text, bilder: p.bilder || [], beitragTyp: p.beitragTyp, optionen: p.optionen || [], myVoteOptionIds: p.myVoteOptionIds || [], multiChoice: p.multiChoice, _tab: tab });
? `<img src="data:image/png;base64,${p.authorPicture}" alt="">` 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 privacyLabel = !p.isPublic ? ' <span style="font-size:0.7rem;color:var(--color-muted);">🔒</span>' : '';
const groupBadge = p.postType === 'GROUP' && p.gruppeId 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>` ? `<span class="gruppe-badge" onclick="event.stopPropagation()">👥 <a href="/community/gruppe.html?id=${p.gruppeId}" onclick="event.stopPropagation()">${esc(p.gruppeName)}</a></span>`
: ''; : '';
const bildHtml = bilderCarousel(p.bilder, p.postId); 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>`;
}
let umfrageHtml = ''; // ── Umfrage-HTML (Feed-spezifisch, da Vote-Handler Seiten-State braucht) ──
if (p.beitragTyp === 'UMFRAGE' && p.optionen && p.optionen.length > 0) { function buildUmfrageHtml(postId, optionen, myVoteOptionIds, onVoteAttrFn) {
const totalVotes = p.optionen.reduce((s, o) => s + o.stimmenCount, 0); if (!optionen || !optionen.length) return '';
umfrageHtml = '<div style="margin-top:0.5rem;">' + p.optionen.map(o => { 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 pct = totalVotes > 0 ? Math.round(o.stimmenCount / totalVotes * 100) : 0;
const voted = p.myVoteOptionIds && p.myVoteOptionIds.includes(o.optionId); const voted = myVoteOptionIds && myVoteOptionIds.includes(o.optionId);
return `<div class="umfrage-option-bar${voted ? ' voted' : ''}" onclick="event.stopPropagation(); votePost('${p.postId}','${o.optionId}','${tab}','${p.postType}')"> 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-fill" style="width:${pct}%"></div>
<div class="umfrage-bar-content"><span>${esc(o.text)}</span><span>${pct}%</span></div> <div class="umfrage-bar-content"><span>${esc(o.text)}</span><span>${pct}%</span></div>
</div>`; </div>`;
}).join('') + `<div class="umfrage-total">${totalVotes} Stimme${totalVotes !== 1 ? 'n' : ''}</div></div>`; }).join('') + `<div class="umfrage-total">${totalVotes} Stimme${totalVotes!==1?'n':''}</div></div>`;
} }
const canDelete = p.postType === 'FEED' && p.authorId === myUserId; // ── Post-Bearbeitung (Feed) ──
const deleteBtn = canDelete function startFeedEdit(postId) {
? `<button class="post-action-btn post-delete" onclick="event.stopPropagation(); deletePost('${p.postId}')">🗑</button>` startPostEdit({ postId, prefix: 'p', data: feedPostCache.get(postId), editBilderMap: feedEditBilder,
: ''; saveFn: 'saveFeedEdit', cancelFn: 'cancelFeedEdit',
const meldenBtn = p.authorId !== myUserId addImgFn: 'feedEditAddImg', addOptionFn: 'feedEditAddOption', rmImgFn: 'feedEditRmImg' });
? `<button class="post-action-btn" onclick="event.stopPropagation(); openMeldungDialog('POST','${p.postId}')" title="Melden" style="color:var(--color-muted)">⚑</button>` }
: ''; function cancelFeedEdit(postId) { cancelPostEdit(postId, 'p', feedEditBilder); }
function feedEditRmImg(postId, idx) { feedEditBilder.get(postId).splice(idx, 1); _renderEditThumbs(feedEditBilder, postId, 'p', 'feedEditRmImg'); }
const gruppeIdAttr = p.gruppeId ? ` data-gruppe-id="${p.gruppeId}"` : ''; function feedEditAddImg(input, postId) {
const hasTarget = !!p.targetUrl; [...input.files].forEach(f => processImageFile(f, feedEditBilder.get(postId), () => _renderEditThumbs(feedEditBilder, postId, 'p', 'feedEditRmImg')));
const cardClick = hasTarget input.value = '';
? `window.location.href='${p.targetUrl}'` }
: `openLb('${p.postId}','${p.postType}')`; function feedEditAddOption(postId) { editAddOptionRow(`peo-${postId}`); }
const commentBtn = hasTarget ? '' : ` async function saveFeedEdit(postId) {
<button class="post-action-btn" onclick="event.stopPropagation(); openLb('${p.postId}','${p.postType}')"> const cached = feedPostCache.get(postId);
💬 <span id="kc-${p.postId}">${p.kommentarCount}</span> await savePostEdit({ postId, prefix: 'p', endpoint: `/feed/posts/${postId}`,
</button>`; isUmfrage: cached?.beitragTyp === 'UMFRAGE', editBilderMap: feedEditBilder,
return `<div class="post-card" id="pc-${p.postId}"${gruppeIdAttr} onclick="${cardClick}" style="cursor:pointer;"> onSuccess: updated => {
<div class="post-header"> feedPostCache.set(postId, { ...cached, text: updated.text, bilder: updated.bilder || [], optionen: updated.optionen || [], multiChoice: updated.multiChoice });
<div class="post-avatar">${avatarHtml}</div> applyPostEditDom(postId, 'p', updated,
<div> buildUmfrageHtml(postId, updated.optionen, cached?.myVoteOptionIds || [],
<div class="post-author"><a href="/community/benutzer.html?userId=${p.authorId}" style="color:inherit;text-decoration:none;" onclick="event.stopPropagation()">${esc(p.authorName)}</a>${privacyLabel}</div> (pid, oid) => `event.stopPropagation(); votePost('${pid}','${oid}','${cached?._tab}','FEED')`));
<div class="post-meta">${fmtDate(p.createdAt)}${groupBadge}</div> }
</div> });
${deleteBtn}
</div>
<div class="post-text">${renderTextWithHashtags(p.text)}</div>
${bildHtml}
${umfrageHtml}
<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>`;
} }
// ── Compose ── // ── Compose ──
function toggleUmfrage() { function selectComposeBilder(input) {
const isUmfrage = document.querySelector('input[name="beitragTyp"]:checked').value === 'UMFRAGE'; [...input.files].forEach(f => processImageFile(f, composeBilderArr, renderComposeThumbs));
document.getElementById('umfrageOptions').style.display = isUmfrage ? '' : 'none'; input.value = '';
document.getElementById('multiChoiceRow').style.display = isUmfrage ? '' : 'none';
if (isUmfrage && document.getElementById('optionList').children.length === 0) {
addOption(); addOption();
}
} }
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() { function addOption() {
const list = document.getElementById('optionList'); const list = document.getElementById('optionList');
const idx = list.children.length; const row = document.createElement('div'); row.className = 'umfrage-option-row';
const row = document.createElement('div'); row.innerHTML = `<input type="text" placeholder="Option ${list.children.length + 1}" maxlength="100">
row.className = 'umfrage-option-row';
row.innerHTML = `<input type="text" placeholder="Option ${idx + 1}" maxlength="100">
<button onclick="this.parentElement.remove()">✕</button>`; <button onclick="this.parentElement.remove()">✕</button>`;
list.appendChild(row); list.appendChild(row);
} }
function selectComposeBilder(input) { // Drag & Drop Compose
[...input.files].forEach(f => { if (f.type.startsWith('image/')) processImageFile(f); }); const _compose = document.getElementById('compose');
input.value = ''; _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)); });
function processImageFile(file) {
if (!file || !file.type.startsWith('image/')) return;
const reader = new FileReader();
reader.onload = e => {
const img = new Image();
img.onload = () => {
const maxSize = 1024;
const canvas = document.createElement('canvas');
const scale = Math.min(maxSize / img.width, maxSize / img.height, 1);
canvas.width = Math.round(img.width * scale);
canvas.height = Math.round(img.height * scale);
canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height);
const data = canvas.toDataURL('image/jpeg', 0.85).split(',')[1];
composeBilderArr.push(data);
renderComposeThumbs();
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
}
function renderComposeThumbs() {
const container = document.getElementById('composeThumbs');
container.innerHTML = '';
composeBilderArr.forEach((b, i) => {
const div = document.createElement('div');
div.className = 'compose-thumb';
div.innerHTML = `<img src="data:image/jpeg;base64,${b}" alt="">
<button class="compose-thumb-remove" onclick="removeThumb(${i})" title="Entfernen">✕</button>`;
container.appendChild(div);
});
container.style.display = composeBilderArr.length > 0 ? 'flex' : 'none';
}
function removeThumb(idx) {
composeBilderArr.splice(idx, 1);
renderComposeThumbs();
}
// ── Drag & Drop ──
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));
});
async function submitPost() { async function submitPost() {
const text = document.getElementById('composeText').value.trim(); const text = document.getElementById('composeText').value.trim();
const hasUmfrage = document.getElementById('umfrageOptions').style.display !== 'none';
if (!text && composeBilderArr.length === 0) return; if (!text && composeBilderArr.length === 0) return;
const beitragTyp = document.querySelector('input[name="beitragTyp"]:checked').value;
const multiChoice = document.getElementById('multiChoice').checked; const multiChoice = document.getElementById('multiChoice').checked;
const isPublic = document.getElementById('isPublic').checked; const isPublic = document.getElementById('isPublic').checked;
let optionen = []; let optionen = [];
if (beitragTyp === 'UMFRAGE') { if (hasUmfrage) {
optionen = Array.from(document.getElementById('optionList').querySelectorAll('input')) optionen = Array.from(document.getElementById('optionList').querySelectorAll('input')).map(i=>i.value.trim()).filter(v=>v);
.map(i => i.value.trim()).filter(v => v);
if (optionen.length < 2) { alert('Mindestens 2 Optionen erforderlich.'); return; } if (optionen.length < 2) { alert('Mindestens 2 Optionen erforderlich.'); return; }
} }
const res = await fetch('/feed/posts', { const res = await fetch('/feed/posts', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ beitragTyp, text, multiChoice, optionen, bilder: [...composeBilderArr], isPublic }) body: JSON.stringify({ beitragTyp: hasUmfrage?'UMFRAGE':'TEXT', text, multiChoice, optionen, bilder:[...composeBilderArr], isPublic })
}); });
if (!res.ok) return; if (!res.ok) return;
const post = await res.json(); const post = await res.json();
// Reset compose
document.getElementById('composeText').value = ''; document.getElementById('composeText').value = '';
composeBilderArr = []; composeBilderArr = []; renderComposeThumbs(); resetUmfrage();
renderComposeThumbs();
document.querySelector('input[name="beitragTyp"][value="TEXT"]').checked = true;
toggleUmfrage();
document.getElementById('multiChoice').checked = false; document.getElementById('multiChoice').checked = false;
document.getElementById('isPublic').checked = false; document.getElementById('isPublic').checked = false;
document.getElementById('optionList').innerHTML = '';
// Prepend to mine feed
document.getElementById('mineEmpty').style.display = 'none'; document.getElementById('mineEmpty').style.display = 'none';
document.getElementById('mineFeed').insertAdjacentHTML('afterbegin', renderPostCard(post, 'mine')); document.getElementById('mineFeed').insertAdjacentHTML('afterbegin', renderPostCard(post, 'mine'));
if (isPublic) { if (isPublic) {
document.getElementById('publicEmpty').style.display = 'none'; document.getElementById('publicEmpty').style.display = 'none';
document.getElementById('publicFeed').insertAdjacentHTML('afterbegin', renderPostCard(post, 'public')); document.getElementById('publicFeed').insertAdjacentHTML('afterbegin', renderPostCard(post, 'public'));
@@ -492,18 +345,17 @@
// ── Like ── // ── Like ──
async function likePost(postId, postType) { async function likePost(postId, postType) {
let likeEndpoint; let ep;
if (postType === 'GROUP') { if (postType === 'GROUP') {
const card = document.getElementById('pc-' + postId); const gruppeId = document.getElementById('pc-'+postId)?.dataset?.gruppeId;
const gruppeId = card?.dataset?.gruppeId;
if (!gruppeId) return; if (!gruppeId) return;
likeEndpoint = `/gruppen/${gruppeId}/posts/${postId}/like`; ep = `/gruppen/${gruppeId}/posts/${postId}/like`;
} else { } else {
likeEndpoint = `/feed/posts/${postId}/like`; ep = `/feed/posts/${postId}/like`;
} }
await fetch(likeEndpoint, { method: 'POST' }); await fetch(ep, { method:'POST' });
const btn = document.getElementById('lk-' + postId); const btn = document.getElementById('lk-'+postId);
const lc = document.getElementById('lkc-' + postId); const lc = document.getElementById('lkc-'+postId);
const was = btn.classList.contains('active'); const was = btn.classList.contains('active');
btn.classList.toggle('active', !was); btn.classList.toggle('active', !was);
lc.textContent = parseInt(lc.textContent) + (was ? -1 : 1); lc.textContent = parseInt(lc.textContent) + (was ? -1 : 1);
@@ -512,115 +364,55 @@
// ── Vote ── // ── Vote ──
async function votePost(postId, optionId, tab, postType) { async function votePost(postId, optionId, tab, postType) {
if (postType === 'GROUP') { if (postType === 'GROUP') {
const card = document.getElementById('pc-' + postId); const gruppeId = document.getElementById('pc-'+postId)?.dataset?.gruppeId;
const gruppeId = card?.dataset?.gruppeId;
if (!gruppeId) return; if (!gruppeId) return;
await fetch(`/gruppen/${gruppeId}/posts/${postId}/vote`, { await fetch(`/gruppen/${gruppeId}/posts/${postId}/vote`, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({optionId}) });
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ optionId })
});
} else { } else {
await fetch('/feed/posts/' + postId + '/vote', { await fetch(`/feed/posts/${postId}/vote`, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({optionId}) });
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ optionId })
});
} }
reloadPost(postId, tab);
}
async function reloadPost(postId, tab) {
const state = feedState[tab]; const state = feedState[tab];
state.page = 0; state.hasMore = true; state.loaded = false; state.page = 0; state.hasMore = true; state.loaded = false;
document.getElementById(tab + 'Feed').innerHTML = ''; document.getElementById(tab+'Feed').innerHTML = '';
document.getElementById(tab + 'Empty').style.display = 'none'; document.getElementById(tab+'Empty').style.display = 'none';
await loadFeed(tab); await loadFeed(tab);
} }
// ── Delete ── // ── Delete ──
async function deletePost(postId) { async function deletePost(postId) {
if (!confirm('Post löschen?')) return; if (!confirm('Post löschen?')) return;
const res = await fetch('/feed/posts/' + postId, { method: 'DELETE' }); const res = await fetch('/feed/posts/' + postId, { method:'DELETE' });
if (res.ok) { if (res.ok) document.getElementById('pc-'+postId)?.remove();
document.getElementById('pc-' + postId)?.remove();
}
} }
// ── Lightbox ── // ── Lightbox (openLb bleibt lokal, da Seiten-Cache nötig) ──
function openLb(postId, postType) { function openLb(postId, postType) {
activeLbPostId = postId;
activeLbPostType = postType;
const card = document.getElementById('pc-' + postId); const card = document.getElementById('pc-' + postId);
if (card) { if (card) {
const clone = card.cloneNode(true); const clone = card.cloneNode(true);
clone.querySelectorAll('.post-actions').forEach(el => el.remove()); clone.querySelectorAll('.post-actions').forEach(el => el.remove());
document.getElementById('lbPostBody').innerHTML = clone.innerHTML; document.getElementById('lbPostBody').innerHTML = clone.innerHTML;
_lbSetupContent(postId, 'p', feedPostCache.get(postId)?.bilder);
} }
loadLbComments(postId, postType); loadLbComments(postId, postType);
document.getElementById('postLightbox').classList.add('open'); document.getElementById('postLightbox').classList.add('open');
document.body.style.overflow = 'hidden';
} }
function openLbWithData(p) { function openLbWithData(p) {
activeLbPostId = p.postId;
activeLbPostType = p.postType || 'FEED';
const tempDiv = document.createElement('div'); const tempDiv = document.createElement('div');
tempDiv.innerHTML = renderPostCard(p, 'mine'); tempDiv.innerHTML = renderPostCard(p, 'mine');
const card = tempDiv.firstElementChild; const card = tempDiv.firstElementChild;
if (card) { if (card) {
card.querySelectorAll('.post-actions').forEach(el => el.remove()); card.querySelectorAll('.post-actions').forEach(el => el.remove());
document.getElementById('lbPostBody').innerHTML = card.innerHTML; document.getElementById('lbPostBody').innerHTML = card.innerHTML;
_lbSetupContent(p.postId, 'p', p.bilder);
} }
loadLbComments(p.postId, p.postType || 'FEED'); loadLbComments(p.postId, p.postType || 'FEED');
document.getElementById('postLightbox').classList.add('open'); document.getElementById('postLightbox').classList.add('open');
document.body.style.overflow = 'hidden';
} }
function closeLb() { document.getElementById('postLightbox').addEventListener('click', e => { if (e.target === document.getElementById('postLightbox')) closeLb(); });
document.getElementById('postLightbox').classList.remove('open');
activeLbPostId = null;
activeLbPostType = null;
}
document.getElementById('postLightbox').addEventListener('click', e => {
if (e.target === document.getElementById('postLightbox')) closeLb();
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && document.getElementById('postLightbox').classList.contains('open')) closeLb();
});
async function loadLbComments(postId, postType) {
const targetType = postType === 'GROUP' ? 'GROUP_POST' : 'FEED_POST';
const res = await fetch(`/social/kommentare?targetType=${targetType}&targetId=${postId}`);
const comments = await res.json();
document.getElementById('lbCommentsList').innerHTML = comments.length === 0
? '<p style="color:var(--color-muted);font-size:0.82rem;margin:0.4rem;">Noch keine Kommentare.</p>'
: comments.map(k => renderKommentarHtml(k, targetType, postId, { myUserId })).join('');
}
async function postLbComment() {
if (!activeLbPostId) return;
const input = document.getElementById('lbCommentInput');
const text = input.value.trim();
if (!text) return;
const targetType = activeLbPostType === 'GROUP' ? 'GROUP_POST' : 'FEED_POST';
await fetch('/social/kommentare', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetType, targetId: activeLbPostId, text })
});
input.value = '';
await loadLbComments(activeLbPostId, activeLbPostType);
const kcEl = document.getElementById('kc-' + activeLbPostId);
if (kcEl) kcEl.textContent = parseInt(kcEl.textContent) + 1;
}
// renderKommentarHtml und toggleKommentarLike kommen aus shared.js
async function deleteKommentar(kommentarId, targetType, targetId) {
await fetch('/social/kommentare/' + kommentarId, { method: 'DELETE' });
await loadLbComments(targetId, activeLbPostType);
}
// toggleEmojiPicker, insertEmoji kommen aus shared.js
// esc, fmtDate kommen aus shared.js
</script> </script>
</body> </body>
</html> </html>

View File

@@ -7,14 +7,8 @@
<title>Gruppe xXx Sphere</title> <title>Gruppe xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css"> <link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css"> <link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="/css/community.css">
<style> <style>
.tabs { display:flex; gap:0; margin-bottom:1.5rem; border-bottom:1px solid var(--color-secondary); }
.tab-btn { background:none; border:none; border-bottom:3px solid transparent; border-radius:0; padding:0.6rem 1.25rem; font-size:0.95rem; font-weight:600; color:var(--color-muted); cursor:pointer; margin-bottom:-1px; transition:color 0.15s,border-color 0.15s; }
.tab-btn:hover { color:var(--color-text); background:none; }
.tab-btn.active { color:var(--color-primary); border-bottom-color:var(--color-primary); }
.tab-panel { display:none; }
.tab-panel.active { display:block; }
/* Header */ /* Header */
.gruppe-header { display:flex; align-items:center; gap:1rem; margin-bottom:1.5rem; flex-wrap:wrap; } .gruppe-header { display:flex; align-items:center; gap:1rem; margin-bottom:1.5rem; flex-wrap:wrap; }
.gruppe-avatar { width:72px; height:72px; border-radius:12px; background:var(--color-secondary); display:flex; align-items:center; justify-content:center; font-size:2rem; flex-shrink:0; overflow:hidden; } .gruppe-avatar { width:72px; height:72px; border-radius:12px; background:var(--color-secondary); display:flex; align-items:center; justify-content:center; font-size:2rem; flex-shrink:0; overflow:hidden; }
@@ -24,48 +18,9 @@
.gruppe-header-actions { margin-left:auto; display:flex; gap:0.5rem; } .gruppe-header-actions { margin-left:auto; display:flex; gap:0.5rem; }
.gruppe-header-actions button, .gruppe-header-actions a.btn { margin:0; width:auto; padding:0.4rem 0.9rem; font-size:0.85rem; } .gruppe-header-actions button, .gruppe-header-actions a.btn { margin:0; width:auto; padding:0.4rem 0.9rem; font-size:0.85rem; }
/* Posts */ /* Compose-Typ (Gruppe-spezifisch) */
.post-compose { background:var(--color-card); border:1px solid var(--color-secondary); border-radius:10px; padding:1rem; margin-bottom:1rem; }
.compose-type { display:flex; gap:1.5rem; margin-bottom:0.75rem; } .compose-type { display:flex; gap:1.5rem; margin-bottom:0.75rem; }
.compose-type label { display:flex; align-items:center; gap:0.4rem; font-size:0.9rem; cursor:pointer; } .compose-type label { display:flex; align-items:center; gap:0.4rem; font-size:0.9rem; cursor:pointer; }
.post-compose textarea { width:100%; padding:0.6rem 0.85rem; border:1px solid var(--color-secondary); border-radius:6px; background:var(--color-secondary); color:var(--color-text); font-size:0.95rem; outline:none; transition:border-color 0.2s; resize:vertical; min-height:70px; box-sizing:border-box; }
.post-compose textarea:focus { border-color:var(--color-primary); }
.compose-thumbs { display:none; flex-wrap:wrap; gap:0.5rem; margin-top:0.5rem; }
.compose-thumb { position:relative; width:64px; height:64px; flex-shrink:0; }
.compose-thumb img { width:64px; height:64px; object-fit:cover; border-radius:6px; display:block; }
.compose-thumb-remove { position:absolute; top:-5px; right:-5px; background:rgba(0,0,0,0.7); border:none; color:#fff; width:18px; height:18px; border-radius:50%; font-size:0.65rem; cursor:pointer; display:flex; align-items:center; justify-content:center; padding:0; margin:0; width:auto; line-height:1; }
.compose-action-btn { background:none; border:1px solid var(--color-secondary); color:var(--color-muted); border-radius:6px; padding:0.35rem 0.6rem; font-size:0.95rem; cursor:pointer; margin:0; width:auto; transition:border-color 0.15s,color 0.15s; }
.compose-action-btn:hover { border-color:var(--color-primary); color:var(--color-primary); background:none; }
label.compose-action-btn { display:inline-flex; align-items:center; }
.umfrage-options { margin-top:0.5rem; }
.post-bild { width:100%; max-height:400px; object-fit:contain; border-radius:6px; margin-top:0.5rem; display:block; }
.umfrage-option-row { display:flex; gap:0.5rem; margin-bottom:0.4rem; }
.umfrage-option-row input { flex:1; }
.umfrage-option-row button { width:auto; margin:0; padding:0.3rem 0.6rem; font-size:0.8rem; }
.compose-footer { display:flex; justify-content:space-between; align-items:center; margin-top:0.75rem; flex-wrap:wrap; gap:0.5rem; }
.multi-toggle { font-size:0.85rem; display:flex; align-items:center; gap:0.4rem; }
.post-card { background:var(--color-card); border:1px solid var(--color-secondary); border-radius:10px; padding:1rem; margin-bottom:0.9rem; }
.post-header { display:flex; align-items:center; gap:0.7rem; margin-bottom:0.6rem; }
.post-avatar { width:36px; height:36px; border-radius:50%; background:var(--color-secondary); display:flex; align-items:center; justify-content:center; font-size:0.95rem; flex-shrink:0; overflow:hidden; }
.post-avatar img { width:100%; height:100%; object-fit:cover; }
.post-author { font-weight:600; font-size:0.9rem; }
.post-date { font-size:0.75rem; color:var(--color-muted); margin-left:auto; }
.post-text { font-size:0.95rem; line-height:1.5; white-space:pre-wrap; word-break:break-word; }
.post-actions { display:flex; gap:1rem; margin-top:0.75rem; align-items:center; flex-wrap:wrap; }
.post-action-btn { background:none; border:none; color:var(--color-muted); cursor:pointer; font-size:0.85rem; padding:0; display:flex; align-items:center; gap:0.3rem; }
.post-action-btn:hover { color:var(--color-primary); background:none; }
.post-action-btn.active { color:var(--color-primary); }
.post-action-btn.danger:hover { color:#c0392b; }
.post-delete { margin-left:auto; }
/* Umfrage */
.umfrage-option-bar { margin:0.3rem 0; cursor:pointer; border-radius:6px; overflow:hidden; border:1px solid var(--color-secondary); position:relative; transition:border-color 0.15s; }
.umfrage-option-bar:hover { border-color:var(--color-primary); }
.umfrage-option-bar.voted { border-color:var(--color-primary); }
.umfrage-bar-fill { position:absolute; inset:0; background:rgba(var(--color-primary-rgb,180,0,60),0.15); transition:width 0.4s; }
.umfrage-bar-content { position:relative; display:flex; justify-content:space-between; padding:0.45rem 0.75rem; font-size:0.88rem; }
.umfrage-total { font-size:0.78rem; color:var(--color-muted); margin-top:0.3rem; }
/* Kommentare */ /* Kommentare */
.comments-section { margin-top:0.75rem; border-top:1px solid var(--color-secondary); padding-top:0.75rem; } .comments-section { margin-top:0.75rem; border-top:1px solid var(--color-secondary); padding-top:0.75rem; }
@@ -111,26 +66,6 @@
.empty-hint { color:var(--color-muted); font-size:0.9rem; margin-top:0.5rem; } .empty-hint { color:var(--color-muted); font-size:0.9rem; margin-top:0.5rem; }
/* Post lightbox */
.lightbox { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.88); z-index:300; align-items:center; justify-content:center; }
.lightbox.open { display:flex; }
.lb-layout { display:flex; max-width:min(1340px, calc(100vw - 2rem)); width:95vw; height:min(90vh, 1100px); background:var(--color-card); border-radius:12px; overflow:hidden; position:relative; }
.lb-post-side .post-bild { max-height:1024px; }
.lb-close { position:absolute; top:0.6rem; right:0.6rem; background:rgba(0,0,0,0.55); border:none; color:#fff; font-size:1.1rem; width:2rem; height:2rem; border-radius:50%; cursor:pointer; z-index:10; display:flex; align-items:center; justify-content:center; padding:0; margin:0; }
.lb-post-side { flex:1; overflow-y:auto; padding:1.25rem; border-right:1px solid var(--color-secondary); min-width:0; }
.lb-comments-panel { width:300px; flex-shrink:0; display:flex; flex-direction:column; }
.lb-comments-list { flex:1; overflow-y:auto; padding:0.75rem; }
.lb-comment-compose { padding:0.75rem; border-top:1px solid var(--color-secondary); display:flex; flex-direction:column; gap:0.5rem; flex-shrink:0; }
.lb-comment-compose textarea { width:100%; font-size:0.85rem; padding:0.35rem 0.6rem; resize:none; background:var(--color-secondary); border:1px solid var(--color-secondary); border-radius:6px; color:var(--color-text); font-family:inherit; outline:none; transition:border-color 0.2s; box-sizing:border-box; }
.lb-comment-compose textarea:focus { border-color:var(--color-primary); }
.lb-comment-compose-actions { display:flex; gap:0.5rem; justify-content:flex-end; }
.lb-comment-compose button { width:auto; margin:0; padding:0.35rem 0.75rem; font-size:0.8rem; }
@media (max-width:650px) {
.lb-layout { flex-direction:column; height:95vh; }
.lb-post-side { border-right:none; border-bottom:1px solid var(--color-secondary); max-height:55vh; }
.lb-comments-panel { width:100%; }
}
/* Dialog */ /* Dialog */
.dialog-backdrop { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.6); z-index:200; align-items:center; justify-content:center; } .dialog-backdrop { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.6); z-index:200; align-items:center; justify-content:center; }
.dialog-backdrop.visible { display:flex; } .dialog-backdrop.visible { display:flex; }
@@ -164,25 +99,24 @@
<div class="tab-panel active" id="tab-posts"> <div class="tab-panel active" id="tab-posts">
<!-- Compose --> <!-- Compose -->
<div class="post-compose" id="compose" style="display:none;"> <div class="post-compose" id="compose" style="display:none;">
<div class="compose-type">
<label><input type="radio" name="beitragTyp" value="TEXT" checked onchange="toggleUmfrage()"> Text</label>
<label><input type="radio" name="beitragTyp" value="UMFRAGE" onchange="toggleUmfrage()"> Umfrage</label>
</div>
<textarea id="composeText" placeholder="Was möchtest du teilen?" rows="3"></textarea> <textarea id="composeText" placeholder="Was möchtest du teilen?" rows="3"></textarea>
<div class="compose-thumbs" id="composeThumbs"></div> <div class="compose-thumbs" id="composeThumbs"></div>
<div class="umfrage-options" id="umfrageOptions" style="display:none;"> <div class="umfrage-options" id="umfrageOptions" style="display:none;">
<div id="optionList"></div> <div id="optionList"></div>
<button onclick="addOption()" style="width:auto; margin:0; padding:0.3rem 0.75rem; font-size:0.8rem; margin-top:0.4rem;">+ Option</button> <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="multi-toggle">
<input type="checkbox" id="multiChoice"> Mehrfachauswahl möglich
</label>
</div>
</div> </div>
<div class="compose-footer"> <div class="compose-footer">
<label class="multi-toggle" id="multiChoiceRow" style="display:none;">
<input type="checkbox" id="multiChoice"> Multi-Choice
</label>
<div style="display:flex;gap:0.5rem;align-items:center;"> <div style="display:flex;gap:0.5rem;align-items:center;">
<button type="button" class="compose-action-btn" onclick="toggleEmojiPicker(this,'composeText')" title="Emoji einfügen">😊</button> <button type="button" class="compose-action-btn" onclick="toggleEmojiPicker(this,'composeText')" title="Emoji einfügen">😊</button>
<label class="compose-action-btn" title="Fotos hinzufügen">📷 <label class="compose-action-btn" title="Fotos hinzufügen">📷
<input type="file" id="composeBildFile" accept="image/*" multiple style="display:none;" onchange="selectComposeBilder(this)"> <input type="file" id="composeBildFile" accept="image/*" multiple style="display:none;" onchange="selectComposeBilder(this)">
</label> </label>
<button type="button" id="umfrageBtn" class="compose-action-btn" onclick="toggleUmfrage(this)" title="Umfrage hinzufügen">📊</button>
<button onclick="submitPost()" style="width:auto; margin:0;">Veröffentlichen</button> <button onclick="submitPost()" style="width:auto; margin:0;">Veröffentlichen</button>
</div> </div>
</div> </div>
@@ -277,18 +211,19 @@
</div> </div>
<!-- Post lightbox dialog --> <!-- Post lightbox dialog -->
<div class="lightbox" id="postDialog"> <div class="lightbox" id="postLightbox">
<div class="lb-layout"> <div class="lb-layout">
<button class="lb-close" onclick="closePostDialog()"></button> <button class="lb-close" onclick="closeLb()"></button>
<div class="lb-post-side" id="lbPostContent"></div> <div class="lb-post-side" id="lbPostBody"></div>
<div class="lb-comments-panel"> <div class="lb-comments-panel">
<div class="lb-comments-header">Kommentare</div>
<div class="lb-comments-list" id="lbCommentsList"></div> <div class="lb-comments-list" id="lbCommentsList"></div>
<div class="lb-comment-compose"> <div class="lb-comment-compose">
<textarea id="lbCommentInput" placeholder="Kommentieren…" maxlength="500" rows="3" <textarea id="lbCommentInput" placeholder="Kommentieren…" maxlength="500" rows="3"
onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();submitLbComment()}"></textarea> onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();postLbComment()}"></textarea>
<div class="lb-comment-compose-actions"> <div class="lb-comment-compose-actions">
<button type="button" class="compose-action-btn" onclick="toggleEmojiPicker(this,'lbCommentInput')" title="Emoji">😊</button> <button type="button" class="compose-action-btn" onclick="toggleEmojiPicker(this,'lbCommentInput')" title="Emoji">😊</button>
<button onclick="submitLbComment()">Senden</button> <button onclick="postLbComment()">Senden</button>
</div> </div>
</div> </div>
</div> </div>
@@ -374,6 +309,7 @@
if (!meRes.ok) { location.href='/login.html'; return; } if (!meRes.ok) { location.href='/login.html'; return; }
const me = await meRes.json(); const me = await meRes.json();
myId = me.userId; myId = me.userId;
initLb(myId);
await loadGruppe(); await loadGruppe();
await loadPosts(); await loadPosts();
@@ -471,15 +407,24 @@
} }
const gruppeEditBilder = new Map();
function renderPostCard(p) { function renderPostCard(p) {
const canEdit = p.authorId === myId;
const canDelete = (myRole === 'ADMIN' || p.authorId === myId); const canDelete = (myRole === 'ADMIN' || p.authorId === myId);
const av = p.authorPicture ? `<img src="data:image/png;base64,${p.authorPicture}" alt="">` : '◉'; const av = p.authorPicture ? `<img src="data:image/png;base64,${p.authorPicture}" alt="">` : '◉';
const bildHtml = bilderCarousel(p.bilder); const bildHtml = bilderGrid(p.bilder);
const editedLabel = p.editedAt ? ` <span style="font-size:0.75rem;color:var(--color-muted);">(bearbeitet)</span>` : '';
let body = ''; // editable view area: question/text + images
const textStyle = p.beitragTyp === 'UMFRAGE' ? ' style="font-weight:600;margin-bottom:0.5rem;"' : '';
const editableHtml = `<div class="post-text"${textStyle}>${renderTextWithHashtags(p.text)}</div><div id="gpbi-${p.beitragId}">${bildHtml}</div>`;
// poll bars (only for UMFRAGE, not editable)
let barsHtml = '';
if (p.beitragTyp === 'UMFRAGE') { if (p.beitragTyp === 'UMFRAGE') {
const total = p.optionen.reduce((s, o) => s + o.stimmenCount, 0); const total = p.optionen.reduce((s, o) => s + o.stimmenCount, 0);
const bars = p.optionen.map(o => { barsHtml = p.optionen.map(o => {
const pct = total > 0 ? Math.round(o.stimmenCount / total * 100) : 0; const pct = total > 0 ? Math.round(o.stimmenCount / total * 100) : 0;
const voted = p.myVoteOptionIds.includes(o.optionId); const voted = p.myVoteOptionIds.includes(o.optionId);
return `<div class="umfrage-option-bar ${voted?'voted':''}" onclick="event.stopPropagation(); vote('${p.beitragId}','${o.optionId}',this)"> return `<div class="umfrage-option-bar ${voted?'voted':''}" onclick="event.stopPropagation(); vote('${p.beitragId}','${o.optionId}',this)">
@@ -489,23 +434,25 @@
<span>${pct}% (${o.stimmenCount})</span> <span>${pct}% (${o.stimmenCount})</span>
</div> </div>
</div>`; </div>`;
}).join(''); }).join('') + `<div class="umfrage-total">${total} Stimme${total !== 1 ? 'n' : ''} gesamt${p.multiChoice?' · Multi-Choice':''}</div>`;
body = bars + `<div class="umfrage-total">${total} Stimme${total !== 1 ? 'n' : ''} gesamt${p.multiChoice?' · Multi-Choice':''}</div>`;
} else {
body = `<div class="post-text">${renderTextWithHashtags(p.text)}</div>${bildHtml}`;
} }
const rightBtns = (canEdit ? `<button class="post-action-btn" onclick="event.stopPropagation();startGruppeEdit('${p.beitragId}')" title="Bearbeiten" style="color:var(--color-muted)">✏</button>` : '')
+ (canDelete ? `<button class="post-action-btn danger post-delete" onclick="event.stopPropagation(); deletePost('${p.beitragId}',this)">✕</button>` : '');
return ` return `
<div class="post-card" id="post-${p.beitragId}" onclick="openPostDialog('${p.beitragId}')" style="cursor:pointer;"> <div class="post-card" id="post-${p.beitragId}" onclick="openPostDialog('${p.beitragId}')" style="cursor:pointer;">
<div class="post-header"> <div class="post-header">
<div class="post-avatar">${av}</div> <div class="post-avatar"><a href="/community/benutzer.html?userId=${p.authorId}" onclick="event.stopPropagation()" style="display:contents;">${av}</a></div>
<div> <div>
<div class="post-author"><a href="/community/benutzer.html?userId=${p.authorId}" style="color:inherit;text-decoration:none;" onclick="event.stopPropagation()">${esc(p.authorName)}</a></div> <div class="post-author"><a href="/community/benutzer.html?userId=${p.authorId}" style="color:inherit;text-decoration:none;" onclick="event.stopPropagation()">${esc(p.authorName)}</a></div>
<div class="post-meta" id="gpm-${p.beitragId}">${fmtDate(p.createdAt)}${editedLabel}</div>
</div> </div>
<div class="post-date">${fmtDate(p.createdAt)}</div> ${rightBtns ? `<div style="margin-left:auto;display:flex;gap:0.25rem;">${rightBtns}</div>` : ''}
</div> </div>
${p.beitragTyp === 'UMFRAGE' ? `<div style="font-weight:600;margin-bottom:0.5rem;">${renderTextWithHashtags(p.text)}</div>${bildHtml}` : ''} <div id="gpva-${p.beitragId}">${editableHtml}</div>
${body} <div id="gpea-${p.beitragId}" style="display:none;"></div>
<div id="gpum-${p.beitragId}">${barsHtml}</div>
<div class="post-actions"> <div class="post-actions">
<button class="post-action-btn ${p.likedByMe?'active':''}" onclick="event.stopPropagation(); toggleLike('${p.beitragId}',this)" id="like-btn-${p.beitragId}"> <button class="post-action-btn ${p.likedByMe?'active':''}" onclick="event.stopPropagation(); toggleLike('${p.beitragId}',this)" id="like-btn-${p.beitragId}">
♥ <span id="like-count-${p.beitragId}">${p.likeCount}</span> ♥ <span id="like-count-${p.beitragId}">${p.likeCount}</span>
@@ -514,11 +461,162 @@
💬 <span id="kmt-count-${p.beitragId}">${p.kommentarCount}</span> 💬 <span id="kmt-count-${p.beitragId}">${p.kommentarCount}</span>
</button> </button>
${!p.reported ? `<button class="post-action-btn" onclick="event.stopPropagation(); reportPost('${p.beitragId}',this)">⚑ Melden</button>` : '<span style="font-size:0.78rem;color:var(--color-muted);">Gemeldet</span>'} ${!p.reported ? `<button class="post-action-btn" onclick="event.stopPropagation(); reportPost('${p.beitragId}',this)">⚑ Melden</button>` : '<span style="font-size:0.78rem;color:var(--color-muted);">Gemeldet</span>'}
${canDelete ? `<button class="post-action-btn danger post-delete" onclick="event.stopPropagation(); deletePost('${p.beitragId}',this)">✕</button>` : ''}
</div> </div>
</div>`; </div>`;
} }
function buildGruppeUmfrageHtml(beitragId, optionen, myVoteOptionIds, multiChoice) {
if (!optionen || optionen.length === 0) return '';
const total = optionen.reduce((s, o) => s + o.stimmenCount, 0);
return optionen.map(o => {
const pct = total > 0 ? Math.round(o.stimmenCount / total * 100) : 0;
const voted = myVoteOptionIds.includes(o.optionId);
return `<div class="umfrage-option-bar ${voted?'voted':''}" onclick="event.stopPropagation(); vote('${beitragId}','${o.optionId}',this)">
<div class="umfrage-bar-fill" style="width:${pct}%"></div>
<div class="umfrage-bar-content"><span>${esc(o.text)}</span><span>${pct}% (${o.stimmenCount})</span></div>
</div>`;
}).join('') + `<div class="umfrage-total">${total} Stimme${total !== 1 ? 'n' : ''} gesamt${multiChoice?' · Multi-Choice':''}</div>`;
}
function startGruppeEdit(beitragId) {
const post = allPosts.find(p => p.beitragId === beitragId);
if (!post) return;
gruppeEditBilder.set(beitragId, [...(post.bilder || [])]);
document.getElementById('gpva-' + beitragId).style.display = 'none';
document.getElementById('gpum-' + beitragId).style.display = 'none';
const isUmfrage = post.beitragTyp === 'UMFRAGE';
const optionenHtml = isUmfrage
? `<div id="gpeo-${beitragId}" style="margin-top:0.5rem;">${(post.optionen || []).map((o) =>
`<div class="umfrage-option-row">
<input type="text" value="${esc(o.text)}" maxlength="200" data-option-id="${o.optionId}"
style="flex:1;padding:0.4rem 0.6rem;border:1px solid var(--color-secondary);border-radius:6px;background:var(--color-secondary);color:var(--color-text);font-size:0.9rem;"
onmousedown="event.stopPropagation()" onclick="event.stopPropagation()" onkeydown="event.stopPropagation()">
<button onmousedown="event.stopPropagation()" onclick="event.stopPropagation();this.closest('.umfrage-option-row').remove()" style="width:auto;margin:0;padding:0.3rem 0.6rem;font-size:0.8rem;">✕</button>
</div>`).join('')}
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:0.3rem;" onclick="event.stopPropagation()">
<button onmousedown="event.stopPropagation()" onclick="event.stopPropagation();gruppeEditAddOption('${beitragId}')" style="width:auto;margin:0;padding:0.3rem 0.75rem;font-size:0.8rem;">+ Option</button>
<label class="multi-toggle" onmousedown="event.stopPropagation()" onclick="event.stopPropagation()">
<input type="checkbox" id="gpmc-${beitragId}" ${post.multiChoice ? 'checked' : ''}> Mehrfachauswahl möglich
</label>
</div>
</div>`
: '';
const actionRow = `<div style="display:flex;gap:0.5rem;align-items:center;margin-top:0.5rem;" onclick="event.stopPropagation()">
<label class="compose-action-btn" title="Fotos hinzufügen">📷
<input type="file" accept="image/*" multiple style="display:none;" onchange="event.stopPropagation();gruppeEditAddImg(this,'${beitragId}')">
</label>
<button onclick="event.stopPropagation();saveGruppeEdit('${beitragId}')" style="width:auto;margin:0;">Speichern</button>
<button onclick="event.stopPropagation();cancelGruppeEdit('${beitragId}')" style="width:auto;margin:0;background:var(--color-secondary);color:var(--color-text);">Abbrechen</button>
</div>`;
const ea = document.getElementById('gpea-' + beitragId);
ea.style.display = '';
ea.onclick = e => e.stopPropagation();
ea.innerHTML = `
<textarea id="gpet-${beitragId}" style="width:100%;box-sizing:border-box;padding:0.6rem;border:1px solid var(--color-secondary);border-radius:6px;background:var(--color-secondary);color:var(--color-text);font-size:0.95rem;resize:vertical;min-height:70px;" onclick="event.stopPropagation()" onkeydown="event.stopPropagation()">${esc(post.text)}</textarea>
<div class="compose-thumbs" id="gpet-tb-${beitragId}" style="margin-top:0.4rem;"></div>
${optionenHtml}
${actionRow}`;
renderGruppeEditThumbs(beitragId);
}
function cancelGruppeEdit(beitragId) {
document.getElementById('gpva-' + beitragId).style.display = '';
document.getElementById('gpum-' + beitragId).style.display = '';
document.getElementById('gpea-' + beitragId).style.display = 'none';
gruppeEditBilder.delete(beitragId);
}
function renderGruppeEditThumbs(beitragId) {
const bilder = gruppeEditBilder.get(beitragId) || [];
const c = document.getElementById('gpet-tb-' + beitragId);
c.innerHTML = bilder.map((b, i) =>
`<div class="compose-thumb"><img src="data:image/jpeg;base64,${b}" alt="">
<button class="compose-thumb-remove" onclick="event.stopPropagation();gruppeEditRmImg('${beitragId}',${i})">✕</button></div>`
).join('');
c.style.display = bilder.length > 0 ? 'flex' : 'none';
}
function gruppeEditRmImg(beitragId, idx) {
gruppeEditBilder.get(beitragId).splice(idx, 1);
renderGruppeEditThumbs(beitragId);
}
function gruppeEditAddImg(input, beitragId) {
[...input.files].forEach(f => {
if (!f.type.startsWith('image/')) return;
const reader = new FileReader();
reader.onload = e => {
const img = new Image();
img.onload = () => {
const MAX = 1024, canvas = document.createElement('canvas');
const s = Math.min(MAX / img.width, MAX / img.height, 1);
canvas.width = Math.round(img.width * s); canvas.height = Math.round(img.height * s);
canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height);
gruppeEditBilder.get(beitragId).push(canvas.toDataURL('image/jpeg', 0.85).split(',')[1]);
renderGruppeEditThumbs(beitragId);
};
img.src = e.target.result;
};
reader.readAsDataURL(f);
});
input.value = '';
}
function gruppeEditAddOption(beitragId) {
const container = document.getElementById('gpeo-' + beitragId);
const count = container.querySelectorAll('input[type=text]').length;
const row = document.createElement('div');
row.className = 'umfrage-option-row';
row.innerHTML = `<input type="text" placeholder="Option ${count + 1}" maxlength="200"
style="flex:1;padding:0.4rem 0.6rem;border:1px solid var(--color-secondary);border-radius:6px;background:var(--color-secondary);color:var(--color-text);font-size:0.9rem;"
onmousedown="event.stopPropagation()" onclick="event.stopPropagation()" onkeydown="event.stopPropagation()">
<button onmousedown="event.stopPropagation()" onclick="event.stopPropagation();this.closest('.umfrage-option-row').remove()" style="width:auto;margin:0;padding:0.3rem 0.6rem;font-size:0.8rem;">✕</button>`;
container.insertBefore(row, container.querySelector('div:last-child'));
}
async function saveGruppeEdit(beitragId) {
const text = document.getElementById('gpet-' + beitragId).value.trim();
if (!text) return;
const post = allPosts.find(p => p.beitragId === beitragId);
const bilder = gruppeEditBilder.get(beitragId) || [];
const isUmfrageEdit = post?.beitragTyp === 'UMFRAGE';
const optionen = isUmfrageEdit
? Array.from(document.querySelectorAll(`#gpeo-${beitragId} input[type=text]`))
.map(inp => ({ optionId: inp.dataset.optionId || null, text: inp.value.trim() }))
.filter(o => o.text)
: null;
const multiChoice = isUmfrageEdit ? (document.getElementById('gpmc-' + beitragId)?.checked ?? false) : null;
const res = await fetch('/gruppen/' + gruppeId + '/posts/' + beitragId, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, bilder, optionen, multiChoice })
});
if (!res.ok) return;
const updated = await res.json();
const idx = allPosts.findIndex(p => p.beitragId === beitragId);
if (idx >= 0) allPosts[idx] = { ...allPosts[idx], text: updated.text, bilder: updated.bilder || [], optionen: updated.optionen || [], multiChoice: updated.multiChoice };
gruppeEditBilder.delete(beitragId);
const gpva = document.getElementById('gpva-' + beitragId);
gpva.querySelector('.post-text').innerHTML = renderTextWithHashtags(updated.text);
const pbi = document.getElementById('gpbi-' + beitragId);
if (pbi) pbi.innerHTML = bilderGrid(updated.bilder);
gpva.style.display = '';
document.getElementById('gpea-' + beitragId).style.display = 'none';
const gpum = document.getElementById('gpum-' + beitragId);
gpum.innerHTML = buildGruppeUmfrageHtml(beitragId, updated.optionen, post?.myVoteOptionIds || [], post?.multiChoice);
gpum.style.display = '';
const meta = document.getElementById('gpm-' + beitragId);
if (meta && !meta.querySelector('.edited-label')) {
meta.insertAdjacentHTML('beforeend', ' <span class="edited-label" style="font-size:0.75rem;color:var(--color-muted);">(bearbeitet)</span>');
}
}
async function toggleLike(postId, btn) { async function toggleLike(postId, btn) {
const res = await fetch('/gruppen/' + gruppeId + '/posts/' + postId + '/like', { method:'POST' }); const res = await fetch('/gruppen/' + gruppeId + '/posts/' + postId + '/like', { method:'POST' });
if (!res.ok) return; if (!res.ok) return;
@@ -620,17 +718,25 @@
// ── Compose ── // ── Compose ──
function toggleUmfrage() { function toggleUmfrage(btn) {
const isUmfrage = document.querySelector('input[name="beitragTyp"]:checked')?.value === 'UMFRAGE'; const options = document.getElementById('umfrageOptions');
document.getElementById('umfrageOptions').style.display = isUmfrage ? '' : 'none'; const isShowing = options.style.display !== 'none';
document.getElementById('multiChoiceRow').style.display = isUmfrage ? '' : 'none'; options.style.display = isShowing ? 'none' : '';
const placeholder = document.getElementById('composeText'); const placeholder = document.getElementById('composeText');
placeholder.placeholder = isUmfrage ? 'Frage eingeben…' : 'Was möchtest du teilen?'; placeholder.placeholder = isShowing ? 'Was möchtest du teilen?' : 'Frage eingeben…';
if (isUmfrage && document.getElementById('optionList').children.length === 0) { if (btn) btn.classList.toggle('active', !isShowing);
if (!isShowing && document.getElementById('optionList').children.length === 0) {
addOption(); addOption(); addOption(); addOption();
} }
} }
function resetUmfrage() {
document.getElementById('umfrageOptions').style.display = 'none';
document.getElementById('optionList').innerHTML = '';
document.getElementById('composeText').placeholder = 'Was möchtest du teilen?';
document.getElementById('umfrageBtn').classList.remove('active');
}
function addOption() { function addOption() {
const list = document.getElementById('optionList'); const list = document.getElementById('optionList');
const idx = list.children.length; const idx = list.children.length;
@@ -642,11 +748,12 @@
async function submitPost() { async function submitPost() {
const text = document.getElementById('composeText').value.trim(); const text = document.getElementById('composeText').value.trim();
const hasUmfrage = document.getElementById('umfrageOptions').style.display !== 'none';
if (!text) return; if (!text) return;
const beitragTyp = document.querySelector('input[name="beitragTyp"]:checked')?.value || 'TEXT'; const beitragTyp = hasUmfrage ? 'UMFRAGE' : 'TEXT';
let optionen = null; let optionen = null;
let multiChoice = null; let multiChoice = null;
if (beitragTyp === 'UMFRAGE') { if (hasUmfrage) {
optionen = Array.from(document.querySelectorAll('#optionList input')).map(i => i.value.trim()).filter(v => v); optionen = Array.from(document.querySelectorAll('#optionList input')).map(i => i.value.trim()).filter(v => v);
if (optionen.length < 2) { showModal('Hinweis', 'Bitte mindestens 2 Optionen eingeben.', [{ label: 'OK' }]); return; } if (optionen.length < 2) { showModal('Hinweis', 'Bitte mindestens 2 Optionen eingeben.', [{ label: 'OK' }]); return; }
multiChoice = document.getElementById('multiChoice').checked; multiChoice = document.getElementById('multiChoice').checked;
@@ -659,9 +766,7 @@
}); });
if (res.ok || res.status === 201) { if (res.ok || res.status === 201) {
document.getElementById('composeText').value = ''; document.getElementById('composeText').value = '';
document.getElementById('optionList').innerHTML = ''; resetUmfrage();
document.querySelector('input[name="beitragTyp"][value="TEXT"]').checked = true;
toggleUmfrage();
composeBilderArr = []; composeBilderArr = [];
renderComposeThumbs(); renderComposeThumbs();
await loadPosts(); await loadPosts();
@@ -938,28 +1043,22 @@
// ── Post dialog ── // ── Post dialog ──
let lbPostId = null;
async function openPostDialog(postId) { async function openPostDialog(postId) {
lbPostId = postId;
const post = allPosts.find(p => p.beitragId === postId); const post = allPosts.find(p => p.beitragId === postId);
if (!post) return; if (!post) return;
renderLbPost(post); renderLbPost(post);
document.getElementById('postDialog').classList.add('open'); _lbSetupContent(postId, 'gp', post.bilder);
await loadLbComments(); document.getElementById('postLightbox').classList.add('open');
document.body.style.overflow = 'hidden';
await loadLbComments(postId, 'GROUP');
document.getElementById('lbCommentInput').focus(); document.getElementById('lbCommentInput').focus();
} }
function closePostDialog() {
document.getElementById('postDialog').classList.remove('open');
lbPostId = null;
}
function renderLbPost(p) { function renderLbPost(p) {
const canDelete = (myRole === 'ADMIN' || p.authorId === myId); const canDelete = (myRole === 'ADMIN' || p.authorId === myId);
const av = p.authorPicture ? `<img src="data:image/png;base64,${p.authorPicture}" alt="">` : '◉'; const av = p.authorPicture ? `<img src="data:image/png;base64,${p.authorPicture}" alt="">` : '◉';
const bildHtml = bilderCarousel(p.bilder);
let body = ''; let umfrageHtml = '';
if (p.beitragTyp === 'UMFRAGE') { if (p.beitragTyp === 'UMFRAGE') {
const total = p.optionen.reduce((s, o) => s + o.stimmenCount, 0); const total = p.optionen.reduce((s, o) => s + o.stimmenCount, 0);
const bars = p.optionen.map(o => { const bars = p.optionen.map(o => {
@@ -973,19 +1072,23 @@
</div> </div>
</div>`; </div>`;
}).join(''); }).join('');
body = `<div style="font-weight:600;margin-bottom:0.5rem;">${esc(p.text)}</div>${bildHtml}${bars}<div class="umfrage-total">${total} Stimme${total !== 1 ? 'n' : ''} gesamt${p.multiChoice?' · Multi-Choice':''}</div>`; umfrageHtml = bars + `<div class="umfrage-total">${total} Stimme${total !== 1 ? 'n' : ''} gesamt${p.multiChoice?' · Multi-Choice':''}</div>`;
} else {
body = `<div class="post-text">${esc(p.text)}</div>${bildHtml}`;
} }
document.getElementById('lbPostContent').innerHTML = `
const textStyle = p.beitragTyp === 'UMFRAGE' ? ' style="font-weight:600;margin-bottom:0.5rem;"' : '';
document.getElementById('lbPostBody').innerHTML = `
<div class="post-header"> <div class="post-header">
<div class="post-avatar">${av}</div> <div class="post-avatar"><a href="/community/benutzer.html?userId=${p.authorId}" style="display:contents;">${av}</a></div>
<div> <div>
<div class="post-author"><a href="/community/benutzer.html?userId=${p.authorId}" style="color:inherit;text-decoration:none;">${esc(p.authorName)}</a></div> <div class="post-author"><a href="/community/benutzer.html?userId=${p.authorId}" style="color:inherit;text-decoration:none;">${esc(p.authorName)}</a></div>
<div class="post-date">${fmtDate(p.createdAt)}</div> <div class="post-date">${fmtDate(p.createdAt)}</div>
</div> </div>
</div> </div>
${body} <div id="gpva-${p.beitragId}">
<div class="post-text"${textStyle}>${renderTextWithHashtags(p.text)}</div>
<div id="gpbi-${p.beitragId}"></div>
</div>
<div id="gpum-${p.beitragId}">${umfrageHtml}</div>
<div class="post-actions" style="margin-top:0.75rem;"> <div class="post-actions" style="margin-top:0.75rem;">
<button class="post-action-btn ${p.likedByMe?'active':''}" onclick="toggleLikeLb('${p.beitragId}',this)"> <button class="post-action-btn ${p.likedByMe?'active':''}" onclick="toggleLikeLb('${p.beitragId}',this)">
♥ <span id="lb-like-count-${p.beitragId}">${p.likeCount}</span> ♥ <span id="lb-like-count-${p.beitragId}">${p.likeCount}</span>
@@ -1011,41 +1114,7 @@
if (post) { post.likeCount = newCount; post.likedByMe = !isActive; } if (post) { post.likeCount = newCount; post.likedByMe = !isActive; }
} }
async function deleteKommentar(kommentarId) {
await fetch('/social/kommentare/' + kommentarId, { method: 'DELETE' });
await loadLbComments();
}
async function loadLbComments() {
if (!lbPostId) return;
const list = document.getElementById('lbCommentsList');
list.innerHTML = '';
const res = await fetch('/social/kommentare?targetType=GROUP_POST&targetId=' + lbPostId);
if (!res.ok) return;
const kmts = await res.json();
kmts.forEach(k => list.insertAdjacentHTML('beforeend', renderKommentarHtml(k, 'GROUP_POST', lbPostId, { myUserId: myId })));
list.scrollTop = list.scrollHeight;
}
async function submitLbComment() {
if (!lbPostId) return;
const input = document.getElementById('lbCommentInput');
const text = input.value.trim();
if (!text) return;
const res = await fetch('/social/kommentare', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ targetType: 'GROUP_POST', targetId: lbPostId, text })
});
if (res.ok || res.status === 201) {
input.value = '';
await loadLbComments();
const countEl = document.getElementById('kmt-count-' + lbPostId);
if (countEl) countEl.textContent = parseInt(countEl.textContent) + 1;
const post = allPosts.find(p => p.beitragId === lbPostId);
if (post) post.kommentarCount++;
}
}
async function reportPostLb(postId, btn) { async function reportPostLb(postId, btn) {
btn.disabled = true; btn.disabled = true;
@@ -1071,7 +1140,7 @@
if (res.ok || res.status === 204) { if (res.ok || res.status === 204) {
document.getElementById('post-' + postId)?.remove(); document.getElementById('post-' + postId)?.remove();
allPosts = allPosts.filter(p => p.beitragId !== postId); allPosts = allPosts.filter(p => p.beitragId !== postId);
closePostDialog(); closeLb();
if (allPosts.length === 0) { document.getElementById('postsEmpty').style.display = ''; document.getElementById('loadMoreBtn').style.display = 'none'; } if (allPosts.length === 0) { document.getElementById('postsEmpty').style.display = ''; document.getElementById('loadMoreBtn').style.display = 'none'; }
} }
}} }}
@@ -1087,13 +1156,15 @@
if (!res.ok) return; if (!res.ok) return;
await loadPosts(); await loadPosts();
const post = allPosts.find(p => p.beitragId === postId); const post = allPosts.find(p => p.beitragId === postId);
if (post) renderLbPost(post); if (post) {
renderLbPost(post);
_lbSetupContent(postId, 'gp', post.bilder);
}
} }
document.getElementById('postDialog').addEventListener('click', e => { document.getElementById('postLightbox').addEventListener('click', e => {
if (e.target === document.getElementById('postDialog')) closePostDialog(); if (e.target === document.getElementById('postLightbox')) closeLb();
}); });
document.addEventListener('keydown', e => { if (e.key === 'Escape') closePostDialog(); });
init(); init();

View File

@@ -7,6 +7,7 @@
<title>Location xXx Sphere</title> <title>Location xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css"> <link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css"> <link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="/css/community.css">
<style> <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 { 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); } .back-link:hover { color:var(--color-primary); }
@@ -216,6 +217,28 @@
</div> </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 ───────────────────────────────────────────────────── --> <!-- ── Galerie Lightbox ───────────────────────────────────────────────────── -->
<div class="lb" id="lightbox" onclick="closeLightbox()"> <div class="lb" id="lightbox" onclick="closeLightbox()">
<button class="lb-close" onclick="closeLightbox()"></button> <button class="lb-close" onclick="closeLightbox()"></button>
@@ -226,6 +249,8 @@
<script src="/js/icons.js"></script> <script src="/js/icons.js"></script>
<script src="/js/nav.js"></script> <script src="/js/nav.js"></script>
<script src="/js/hashtag.js"></script>
<script src="/js/shared.js"></script>
<script> <script>
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
const locationId = params.get('id'); const locationId = params.get('id');
@@ -303,6 +328,15 @@ async function loadPage() {
isFollowing = !!locDetail.following; isFollowing = !!locDetail.following;
renderPage(); 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) { if (isAdmin) {
const chatWithId = new URLSearchParams(location.search).get('chatWith'); const chatWithId = new URLSearchParams(location.search).get('chatWith');
@@ -315,6 +349,8 @@ async function loadPage() {
} else { } else {
loadEvents(); loadEvents();
} }
await loadLocFeed();
initLocFeedObserver();
} }
function renderPage() { function renderPage() {
@@ -372,6 +408,37 @@ function renderPage() {
</div> </div>
<div class="gallery-grid" id="galleryGrid">${galleryHtml}</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 = ` const eventsSection = `
<div class="section-title"> <div class="section-title">
Veranstaltungen Veranstaltungen
@@ -396,6 +463,7 @@ function renderPage() {
${locHeaderHtml} ${locHeaderHtml}
${hoursHtml} ${hoursHtml}
${gallerySection} ${gallerySection}
${feedSection}
</div> </div>
<div class="tab-panel" id="tab-admins"> <div class="tab-panel" id="tab-admins">
@@ -449,6 +517,7 @@ function renderPage() {
${locHeaderHtml} ${locHeaderHtml}
${hoursHtml} ${hoursHtml}
${gallerySection} ${gallerySection}
${feedSection}
${eventsSection}`; ${eventsSection}`;
} }
} }
@@ -1135,6 +1204,304 @@ async function sendInboxReply() {
} catch { showAlert('Fehler beim Senden.'); input.value = text; } } 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 ────────────────────────────────────────────────────────────────────── // ── Init ──────────────────────────────────────────────────────────────────────
loadPage(); loadPage();
</script> </script>

View File

@@ -0,0 +1,104 @@
/* ── Tabs ── */
.tabs{display:flex;gap:0;margin-bottom:1.5rem;border-bottom:1px solid var(--color-secondary)}
.tab-btn{background:none;border:none;border-bottom:3px solid transparent;border-radius:0;padding:0.6rem 1.25rem;font-size:0.95rem;font-weight:600;color:var(--color-muted);cursor:pointer;margin-bottom:-1px;transition:color 0.15s,border-color 0.15s}
.tab-btn:hover{color:var(--color-text);background:none}
.tab-btn.active{color:var(--color-primary);border-bottom-color:var(--color-primary)}
.tab-panel{display:none}
.tab-panel.active{display:block}
/* ── Post-Compose ── */
.post-compose{background:var(--color-card);border:1px solid var(--color-secondary);border-radius:10px;padding:1rem;margin-bottom:1rem;transition:border-color 0.15s}
.post-compose.drag-over{border-color:var(--color-primary);background:rgba(var(--color-primary-rgb,180,0,60),0.06)}
.post-compose textarea{width:100%;padding:0.6rem 0.85rem;border:1px solid var(--color-secondary);border-radius:6px;background:var(--color-secondary);color:var(--color-text);font-size:0.95rem;outline:none;transition:border-color 0.2s;resize:vertical;min-height:70px;box-sizing:border-box}
.post-compose textarea:focus{border-color:var(--color-primary)}
.compose-thumbs{display:none;flex-wrap:wrap;gap:0.5rem;margin-top:0.5rem}
.compose-thumb{position:relative;width:64px;height:64px;flex-shrink:0}
.compose-thumb img{width:64px;height:64px;object-fit:cover;border-radius:6px;display:block}
.compose-thumb-remove{position:absolute;top:-5px;right:-5px;background:rgba(0,0,0,0.7);border:none;color:#fff;width:18px;height:18px;border-radius:50%;font-size:0.65rem;cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0;margin:0;width:auto;line-height:1}
.compose-footer{display:flex;justify-content:space-between;align-items:center;margin-top:0.75rem;flex-wrap:wrap;gap:0.5rem}
.compose-action-btn{background:none;border:1px solid var(--color-secondary);color:var(--color-muted);border-radius:6px;padding:0.35rem 0.6rem;font-size:0.95rem;cursor:pointer;margin:0;width:auto;transition:border-color 0.15s,color 0.15s}
.compose-action-btn:hover{border-color:var(--color-primary);color:var(--color-primary);background:none}
.compose-action-btn.active{border-color:var(--color-primary);color:var(--color-primary)}
label.compose-action-btn{display:inline-flex;align-items:center}
.multi-toggle{font-size:0.85rem;display:flex;align-items:center;gap:0.4rem}
.privacy-toggle{font-size:0.85rem;display:flex;align-items:center;gap:0.4rem}
/* ── Umfrage-Compose ── */
.umfrage-options{margin-top:0.5rem}
.umfrage-option-row{display:flex;gap:0.5rem;margin-bottom:0.4rem}
.umfrage-option-row input{flex:1}
.umfrage-option-row button{width:auto;margin:0;padding:0.3rem 0.6rem;font-size:0.8rem}
/* ── Post-Card ── */
.post-card{background:var(--color-card);border:1px solid var(--color-secondary);border-radius:10px;padding:1rem;margin-bottom:0.9rem}
.post-card.clickable{cursor:pointer;transition:border-color 0.15s}
.post-card.clickable:hover{border-color:var(--color-primary)}
.post-header{display:flex;align-items:center;gap:0.7rem;margin-bottom:0.6rem}
.post-avatar{width:36px;height:36px;border-radius:50%;background:var(--color-secondary);display:flex;align-items:center;justify-content:center;font-size:0.95rem;flex-shrink:0;overflow:hidden}
.post-avatar img{width:100%;height:100%;object-fit:cover}
.post-author{font-weight:600;font-size:0.9rem}
.post-meta{font-size:0.75rem;color:var(--color-muted)}
.post-date{font-size:0.75rem;color:var(--color-muted);margin-left:auto}
.post-text{font-size:0.95rem;line-height:1.5;white-space:pre-wrap;word-break:break-word}
.post-bild{width:100%;max-height:400px;object-fit:contain;border-radius:6px;margin-top:0.5rem;display:block}
.post-actions{display:flex;gap:1rem;margin-top:0.75rem;align-items:center;flex-wrap:wrap}
.post-action-btn{background:none;border:none;color:var(--color-muted);cursor:pointer;font-size:0.85rem;padding:0;display:flex;align-items:center;gap:0.3rem;margin:0;width:auto}
.post-action-btn:hover{color:var(--color-primary);background:none}
.post-action-btn.active{color:var(--color-primary)}
.post-delete{margin-left:auto}
.post-delete:hover{color:#c0392b !important}
/* ── Umfrage-Bars ── */
.umfrage-option-bar{margin:0.3rem 0;cursor:pointer;border-radius:6px;overflow:hidden;border:1px solid var(--color-secondary);position:relative;transition:border-color 0.15s}
.umfrage-option-bar:hover{border-color:var(--color-primary)}
.umfrage-option-bar.voted{border-color:var(--color-primary)}
.umfrage-bar-fill{position:absolute;inset:0;background:rgba(var(--color-primary-rgb,180,0,60),0.15);transition:width 0.4s}
.umfrage-bar-content{position:relative;display:flex;justify-content:space-between;padding:0.45rem 0.75rem;font-size:0.88rem}
.umfrage-total{font-size:0.78rem;color:var(--color-muted);margin-top:0.3rem}
/* ── Gruppen-Badge / Diverse ── */
.gruppe-badge{display:inline-flex;align-items:center;gap:0.3rem;font-size:0.75rem;color:var(--color-muted);background:var(--color-secondary);border-radius:4px;padding:0.15rem 0.45rem;margin-top:0.1rem}
.gruppe-badge a{color:inherit;text-decoration:none}
.gruppe-badge a:hover{color:var(--color-primary)}
.empty-hint{color:var(--color-muted);font-size:0.9rem;margin-top:0.5rem}
.sentinel{height:1px}
/* ── Lightbox ── */
.lightbox{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.88);z-index:700;align-items:center;justify-content:center}
.lightbox.open{display:flex}
/* Max-Breite: 1024px Bild + 300px Kommentare + Padding; Max-Höhe: 1024px Bild + Header/Text */
.lb-layout{display:flex;max-width:min(1400px,calc(100vw - 2rem));width:95vw;height:min(90vh,1200px);background:var(--color-card);border-radius:12px;overflow:hidden;position:relative}
/* Post-Seite als Flex-Spalte damit das Bild den Platz füllt */
.lb-post-side{flex:1;display:flex;flex-direction:column;overflow:hidden;padding:1.25rem;border-right:1px solid var(--color-secondary);min-width:0}
.lb-post-side>*{flex-shrink:0}
.lb-post-side>.post-header{margin-bottom:0.75rem}
/* View-Area (va): wächst, enthält Bild + Text */
.lb-va{flex:1!important;min-height:0;display:flex;flex-direction:column;gap:0.4rem}
/* Bild-Container: füllt verbleibende Höhe */
.lb-ic{flex:1;min-height:0;position:relative;overflow:hidden;border-radius:4px}
.lb-ic .post-carousel{position:absolute;inset:0;display:flex;flex-direction:column}
.lb-ic .car-slide{display:none}
.lb-ic .car-slide.active{flex:1;min-height:0;display:flex;align-items:center;justify-content:center}
.lb-ic .car-slide img{max-width:100%;max-height:100%;width:auto;height:auto;object-fit:contain;display:block}
.lb-ic .car-indicator{flex-shrink:0;text-align:center;font-size:0.75rem;color:var(--color-muted);padding:0.2rem 0}
/* Post-Text: scrollbar bei langem Inhalt */
.lb-text{flex-shrink:0!important;max-height:100px;overflow-y:auto;font-size:0.95rem;line-height:1.5;white-space:pre-wrap;word-break:break-word}
.lb-close{position:absolute;top:0.6rem;right:0.6rem;background:rgba(0,0,0,0.55);border:none;color:#fff;font-size:1.1rem;width:2rem;height:2rem;border-radius:50%;cursor:pointer;z-index:10;display:flex;align-items:center;justify-content:center;padding:0;margin:0}
.lb-comments-panel{width:300px;flex-shrink:0;display:flex;flex-direction:column}
.lb-comments-header{font-size:0.78rem;font-weight:600;color:var(--color-muted);text-transform:uppercase;letter-spacing:0.06em;padding:0.7rem 1rem;border-bottom:1px solid var(--color-secondary);flex-shrink:0}
.lb-comments-list{flex:1;overflow-y:auto;padding:0.75rem}
.lb-comment-compose{padding:0.75rem;border-top:1px solid var(--color-secondary);display:flex;flex-direction:column;gap:0.5rem;flex-shrink:0}
.lb-comment-compose textarea{width:100%;font-size:0.85rem;padding:0.35rem 0.6rem;resize:none;background:var(--color-secondary);border:1px solid var(--color-secondary);border-radius:6px;color:var(--color-text);font-family:inherit;outline:none;transition:border-color 0.2s;box-sizing:border-box}
.lb-comment-compose textarea:focus{border-color:var(--color-primary)}
.lb-comment-compose-actions{display:flex;gap:0.5rem;justify-content:flex-end}
.lb-comment-compose button{width:auto;margin:0;padding:0.35rem 0.75rem;font-size:0.8rem}
@media(max-width:650px){.lb-layout{flex-direction:column;height:95vh}.lb-post-side{border-right:none;border-bottom:1px solid var(--color-secondary);flex:0 0 58vh}.lb-comments-panel{width:100%;flex:1}}
/* ── Text-only Lightbox (kein Bild → Kommentare unterhalb, volle Breite) ── */
.lb-text-only{flex-direction:column;width:min(680px,calc(100vw - 2rem));height:min(680px,calc(100vw - 2rem),90vh)}
.lb-text-only .lb-post-side{flex:0 0 auto;border-right:none;border-bottom:1px solid var(--color-secondary);overflow-y:auto;max-height:55%}
.lb-text-only .lb-va{flex:0 0 auto!important;min-height:unset}
.lb-text-only .lb-text{max-height:none!important}
.lb-text-only .lb-comments-panel{width:100%;flex:1;min-height:0;display:flex;flex-direction:column}
.lb-text-only .lb-comments-list{flex:1;overflow-y:auto}
.lb-text-only .lb-comment-compose{flex-shrink:0}

View File

@@ -19,6 +19,14 @@
.car-next{right:0.3rem} .car-next{right:0.3rem}
.car-indicator{text-align:center;font-size:0.75rem;color:var(--color-muted);margin-top:0.25rem} .car-indicator{text-align:center;font-size:0.75rem;color:var(--color-muted);margin-top:0.25rem}
/* ── Bilder-Grid (nur im Feed) ── */
.post-img-grid{display:grid;gap:2px;margin:0.5rem auto 0;border-radius:6px;overflow:hidden;flex-shrink:0}
.pig-item{position:relative;overflow:hidden;min-width:0;min-height:0}
.pig-item img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block}
.pig-contain img{object-fit:contain}
.pig-dark{background:#111}.pig-dark .pig-item{background:#111}
.pig-more{position:absolute;inset:0;background:rgba(0,0,0,0.55);display:flex;align-items:center;justify-content:center;font-size:1.8rem;font-weight:700;color:#fff;pointer-events:none}
/* ── Like / Löschen-Buttons ── */ /* ── Like / Löschen-Buttons ── */
.btn-like{background:none;border:1px solid rgba(255,255,255,0.15);border-radius:20px;padding:0.2rem 0.65rem;color:var(--color-muted);font-size:0.78rem;cursor:pointer;display:inline-flex;align-items:center;gap:0.3rem;margin:0;width:auto;transition:border-color 0.15s,color 0.15s} .btn-like{background:none;border:1px solid rgba(255,255,255,0.15);border-radius:20px;padding:0.2rem 0.65rem;color:var(--color-muted);font-size:0.78rem;cursor:pointer;display:inline-flex;align-items:center;gap:0.3rem;margin:0;width:auto;transition:border-color 0.15s,color 0.15s}
.btn-like:hover,.btn-like.liked{border-color:var(--color-primary);color:var(--color-primary)} .btn-like:hover,.btn-like.liked{border-color:var(--color-primary);color:var(--color-primary)}
@@ -110,21 +118,117 @@ document.addEventListener('click', e => {
} }
}); });
// ── Bild-Karussell ──────────────────────────────────────────────────────────── // ── Bild-Karussell (Lightbox/Detail-Ansicht) ─────────────────────────────────
function bilderCarousel(bilder) { function bilderCarousel(bilder) {
if (!bilder || bilder.length === 0) return ''; if (!bilder || bilder.length === 0) return '';
if (bilder.length === 1) {
return `<div style="margin-top:0.5rem;"><img class="post-bild" src="data:image/jpeg;base64,${bilder[0]}" alt=""></div>`;
}
const slides = bilder.map((b, i) => const slides = bilder.map((b, i) =>
`<div class="car-slide${i === 0 ? ' active' : ''}"><img class="post-bild" src="data:image/jpeg;base64,${b}" alt=""></div>` `<div class="car-slide${i === 0 ? ' active' : ''}"><img class="post-bild" src="data:image/jpeg;base64,${b}" alt=""></div>`
).join(''); ).join('');
return `<div class="post-carousel"> const nav = bilder.length > 1
${slides} ? `<button class="car-btn car-prev" onclick="event.stopPropagation();carNav(this,-1)">&#8249;</button>
<button class="car-btn car-prev" onclick="event.stopPropagation();carNav(this,-1)">&#8249;</button>
<button class="car-btn car-next" onclick="event.stopPropagation();carNav(this,1)">&#8250;</button> <button class="car-btn car-next" onclick="event.stopPropagation();carNav(this,1)">&#8250;</button>
<div class="car-indicator"><span class="car-cur">1</span>/${bilder.length}</div> <div class="car-indicator"><span class="car-cur">1</span>&#8202;/&#8202;${bilder.length}</div>`
: '';
return `<div class="post-carousel">${slides}${nav}</div>`;
}
/** Richtet die Lightbox-Inhalte ein:
* View-Area bekommt Flex-Layout damit das Bild den Platz füllt
* Bild-Container bekommt lb-ic-Klasse + Carousel (für alle Bildanzahlen)
* Post-Text bekommt scrollbare Höhenbegrenzung
*/
function _lbSetupContent(postId, prefix, bilder) {
const body = document.getElementById('lbPostBody');
const va = body.querySelector(`#${prefix}va-${postId}`);
if (va) va.classList.add('lb-va');
const hasImages = bilder && bilder.length > 0;
const pbi = body.querySelector(`#${prefix}bi-${postId}`);
if (pbi) {
if (hasImages) {
pbi.classList.add('lb-ic');
pbi.innerHTML = bilderCarousel(bilder);
} else {
pbi.style.display = 'none';
}
}
if (va) va.querySelector('.post-text')?.classList.add('lb-text');
// Text-only layout: kein Bild → Kommentare unterhalb, volle Breite
const layout = document.querySelector('#postLightbox .lb-layout');
if (layout) layout.classList.toggle('lb-text-only', !hasImages);
}
// ── Bilder-Grid (Feed-Karten, orientierungsabhängig) ──────────────────────────
const POST_IMG_SIZE = 500; // px — Breite und Höhe des Bild-Containers
let _pigSeq = 0;
const _pigStore = new Map(); // id → bilder[]
function bilderGrid(bilder) {
if (!bilder || bilder.length === 0) return '';
const S = POST_IMG_SIZE;
const id = 'pig-' + (++_pigSeq);
if (bilder.length === 1) {
// Längere Seite = S, kürzere letterboxed
return `<div class="post-img-grid pig-contain" id="${id}" style="width:${S}px;height:${S}px;grid-template-columns:1fr;grid-template-rows:1fr;">
<div class="pig-item pig-contain"><img src="data:image/jpeg;base64,${bilder[0]}" alt=""></div>
</div>`; </div>`;
}
// 2+ Bilder: Orientierung des ersten Bilds bestimmt das Layout → deferred init
_pigStore.set(id, bilder);
return `<div class="post-img-grid" id="${id}" style="width:${S}px;height:${S}px;">` +
`<img src="data:image/jpeg;base64,${bilder[0]}" style="display:none;position:absolute;" alt="" onload="_pigInit('${id}',this)">` +
`</div>`;
}
function _pigInit(id, probe) {
const bilder = _pigStore.get(id);
if (!bilder) return;
_pigStore.delete(id);
const grid = document.getElementById(id);
if (!grid) return;
const land = probe.naturalWidth >= probe.naturalHeight; // quer = landscape
const n = bilder.length;
const extra = n - 3;
const moreHtml = extra > 0 ? `<div class="pig-more">+${extra}</div>` : '';
grid.innerHTML = ''; // Probe-Bild entfernen
// Fraktionale Werte → Browser berücksichtigt gap automatisch, Trennstrich immer genau in der Mitte
if (n === 2) {
if (land) {
// Quer-erstes Bild → beide übereinander (Trennstrich horizontal in der Mitte)
grid.style.gridTemplateColumns = '1fr';
grid.style.gridTemplateRows = '1fr 1fr';
} else {
// Hochkant-erstes Bild → beide nebeneinander (Trennstrich vertikal in der Mitte)
grid.style.gridTemplateColumns = '1fr 1fr';
grid.style.gridTemplateRows = '1fr';
}
grid.insertAdjacentHTML('beforeend',
`<div class="pig-item"><img src="data:image/jpeg;base64,${bilder[0]}" alt=""></div>` +
`<div class="pig-item"><img src="data:image/jpeg;base64,${bilder[1]}" alt=""></div>`);
} else {
// 3+ Bilder (ab 4 gleiche Darstellung wie bei 3, +N-Overlay auf Zelle 3)
// Dunkler Hintergrund nur hier, damit der Spalt zwischen den Zellen nicht auffällt
grid.classList.add('pig-dark');
grid.style.gridTemplateColumns = '1fr 1fr';
grid.style.gridTemplateRows = '1fr 1fr';
if (land) {
// Quer → Bild 1 oben (volle Breite), Bilder 2+3 nebeneinander unten
grid.insertAdjacentHTML('beforeend',
`<div class="pig-item" style="grid-column:1/3"><img src="data:image/jpeg;base64,${bilder[0]}" alt=""></div>` +
`<div class="pig-item"><img src="data:image/jpeg;base64,${bilder[1]}" alt=""></div>` +
`<div class="pig-item"><img src="data:image/jpeg;base64,${bilder[2]}" alt="">${moreHtml}</div>`);
} else {
// Hochkant → Bild 1 links (volle Höhe), Bilder 2+3 übereinander rechts
grid.insertAdjacentHTML('beforeend',
`<div class="pig-item" style="grid-row:1/3"><img src="data:image/jpeg;base64,${bilder[0]}" alt=""></div>` +
`<div class="pig-item"><img src="data:image/jpeg;base64,${bilder[1]}" alt=""></div>` +
`<div class="pig-item"><img src="data:image/jpeg;base64,${bilder[2]}" alt="">${moreHtml}</div>`);
}
}
} }
function carNav(btn, dir) { function carNav(btn, dir) {
@@ -209,6 +313,7 @@ async function loadReplies(kommentarId) {
const res = await fetch(`/social/kommentare?targetType=KOMMENTAR&targetId=${kommentarId}`); const res = await fetch(`/social/kommentare?targetType=KOMMENTAR&targetId=${kommentarId}`);
const replies = await res.json(); const replies = await res.json();
const section = document.getElementById('replies-' + kommentarId); const section = document.getElementById('replies-' + kommentarId);
replies.reverse();
section.innerHTML = (replies.length === 0 section.innerHTML = (replies.length === 0
? '<p style="color:var(--color-muted);font-size:0.78rem;margin-bottom:0.35rem;">Noch keine Antworten.</p>' ? '<p style="color:var(--color-muted);font-size:0.78rem;margin-bottom:0.35rem;">Noch keine Antworten.</p>'
: replies.map(r => renderReplyHtml(r, kommentarId)).join('')) : replies.map(r => renderReplyHtml(r, kommentarId)).join(''))
@@ -235,3 +340,237 @@ async function deleteReply(replyId, parentId) {
await fetch('/social/kommentare/' + replyId, { method: 'DELETE' }); await fetch('/social/kommentare/' + replyId, { method: 'DELETE' });
await loadReplies(parentId); await loadReplies(parentId);
} }
// ─────────────────────────────────────────────────────────────────────────────
// Gemeinsame Lightbox (standardisierte IDs: #postLightbox, #lbPostBody,
// #lbCommentsList, #lbCommentInput auf allen Feed-Seiten gleich)
// ─────────────────────────────────────────────────────────────────────────────
let _lbMyUserId = null;
let _lbPostId = null;
let _lbPostType = null;
/** Muss nach dem Login mit der eigenen userId aufgerufen werden. */
function initLb(userId) { _lbMyUserId = userId; }
function closeLb() {
document.getElementById('postLightbox')?.classList.remove('open');
document.body.style.overflow = '';
_lbPostId = null; _lbPostType = null;
}
// Escape schließt, Pfeiltasten navigieren das Karussell
document.addEventListener('keydown', e => {
const lb = document.getElementById('postLightbox');
if (!lb?.classList.contains('open')) return;
if (e.key === 'Escape') { closeLb(); return; }
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
const car = lb.querySelector('.post-carousel');
if (!car) return;
const slides = Array.from(car.querySelectorAll('.car-slide'));
if (slides.length <= 1) return;
const cur = slides.findIndex(s => s.classList.contains('active'));
const next = (cur + (e.key === 'ArrowLeft' ? -1 : 1) + slides.length) % slides.length;
slides[cur].classList.remove('active');
slides[next].classList.add('active');
const ind = car.querySelector('.car-cur');
if (ind) ind.textContent = next + 1;
});
async function loadLbComments(postId, postType) {
_lbPostId = postId; _lbPostType = postType;
const targetType = postType === 'GROUP' ? 'GROUP_POST' : 'FEED_POST';
try {
const res = await fetch(`/social/kommentare?targetType=${targetType}&targetId=${postId}`);
const comments = await res.json();
comments.reverse();
document.getElementById('lbCommentsList').innerHTML = comments.length === 0
? '<p style="color:var(--color-muted);font-size:0.82rem;margin:0.4rem;">Noch keine Kommentare.</p>'
: comments.map(k => renderKommentarHtml(k, targetType, postId, { myUserId: _lbMyUserId })).join('');
} catch (_) {}
}
async function postLbComment() {
if (!_lbPostId) return;
const input = document.getElementById('lbCommentInput');
const text = input.value.trim();
if (!text) return;
const targetType = _lbPostType === 'GROUP' ? 'GROUP_POST' : 'FEED_POST';
await fetch('/social/kommentare', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetType, targetId: _lbPostId, text })
});
input.value = '';
await loadLbComments(_lbPostId, _lbPostType);
const kcEl = document.getElementById('kc-' + _lbPostId) || document.getElementById('hkc-' + _lbPostId);
if (kcEl) kcEl.textContent = parseInt(kcEl.textContent || '0') + 1;
}
async function deleteKommentar(kommentarId, targetType, targetId) {
await fetch('/social/kommentare/' + kommentarId, { method: 'DELETE' });
await loadLbComments(targetId, _lbPostType);
}
// ─────────────────────────────────────────────────────────────────────────────
// Bild-Verarbeitung (Compose & Edit)
// ─────────────────────────────────────────────────────────────────────────────
/** Liest eine Bilddatei, skaliert auf max. 1024px, komprimiert auf JPEG 85 %
* und hängt den Base64-String an bilderArr an, dann ruft renderFn() auf. */
function processImageFile(file, bilderArr, renderFn) {
if (!file || !file.type.startsWith('image/')) return;
const reader = new FileReader();
reader.onload = e => {
const img = new Image();
img.onload = () => {
const MAX = 1024, canvas = document.createElement('canvas');
const s = Math.min(MAX / img.width, MAX / img.height, 1);
canvas.width = Math.round(img.width * s); canvas.height = Math.round(img.height * s);
canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height);
bilderArr.push(canvas.toDataURL('image/jpeg', 0.85).split(',')[1]);
renderFn();
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
}
/** Rendert Vorschau-Thumbnails in containerId; rmCallback(idx) wird beim ✕ aufgerufen. */
function renderBilderThumbs(bilderArr, containerId, rmCallback) {
const c = document.getElementById(containerId);
if (!c) return;
c.innerHTML = '';
bilderArr.forEach((b, i) => {
const div = document.createElement('div');
div.className = 'compose-thumb';
const img = document.createElement('img');
img.src = `data:image/jpeg;base64,${b}`;
const btn = document.createElement('button');
btn.className = 'compose-thumb-remove';
btn.textContent = '✕'; btn.title = 'Entfernen';
btn.onclick = ev => { ev.stopPropagation(); rmCallback(i); };
div.appendChild(img); div.appendChild(btn);
c.appendChild(div);
});
c.style.display = bilderArr.length > 0 ? 'flex' : 'none';
}
// ─────────────────────────────────────────────────────────────────────────────
// Post-Bearbeitung (gemeinsam, über Präfix parametrisiert)
// Präfixe: 'p' (feed), 'hp' (userhome), 'gp' (gruppe)
// IDs-Muster: ${prefix}va-, ${prefix}bi-, ${prefix}ea-, ${prefix}um-,
// ${prefix}m-, ${prefix}et-, ${prefix}et-tb-, ${prefix}eo-, ${prefix}mc-
// ─────────────────────────────────────────────────────────────────────────────
/**
* cfg: { postId, prefix, data, editBilderMap,
* saveFn, cancelFn, addImgFn, addOptionFn, rmImgFn }
* Alle Fn-Namen sind Strings (globale Funktionsnamen auf der jeweiligen Seite).
*/
function startPostEdit(cfg) {
const { postId, prefix, data, editBilderMap, saveFn, cancelFn, addImgFn, addOptionFn, rmImgFn } = cfg;
editBilderMap.set(postId, [...(data.bilder || [])]);
document.getElementById(`${prefix}va-${postId}`).style.display = 'none';
document.getElementById(`${prefix}um-${postId}`).style.display = 'none';
const isUmfrage = data.beitragTyp === 'UMFRAGE';
const optionenHtml = isUmfrage
? `<div id="${prefix}eo-${postId}" style="margin-top:0.5rem;">${(data.optionen || []).map(o =>
`<div class="umfrage-option-row">
<input type="text" value="${esc(o.text)}" maxlength="200" data-option-id="${o.optionId}"
style="flex:1;padding:0.4rem 0.6rem;border:1px solid var(--color-secondary);border-radius:6px;background:var(--color-secondary);color:var(--color-text);font-size:0.9rem;"
onmousedown="event.stopPropagation()" onclick="event.stopPropagation()" onkeydown="event.stopPropagation()">
<button onmousedown="event.stopPropagation()" onclick="event.stopPropagation();this.closest('.umfrage-option-row').remove()" style="width:auto;margin:0;padding:0.3rem 0.6rem;font-size:0.8rem;">✕</button>
</div>`).join('')}
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:0.3rem;" onclick="event.stopPropagation()">
<button onmousedown="event.stopPropagation()" onclick="event.stopPropagation();${addOptionFn}('${postId}')" style="width:auto;margin:0;padding:0.3rem 0.75rem;font-size:0.8rem;">+ Option</button>
<label class="multi-toggle" onmousedown="event.stopPropagation()" onclick="event.stopPropagation()">
<input type="checkbox" id="${prefix}mc-${postId}" ${data.multiChoice ? 'checked' : ''}> Mehrfachauswahl möglich
</label>
</div>
</div>`
: '';
const actionRow = `<div style="display:flex;gap:0.5rem;align-items:center;margin-top:0.5rem;" onclick="event.stopPropagation()">
<label class="compose-action-btn" title="Fotos hinzufügen">📷
<input type="file" accept="image/*" multiple style="display:none;" onchange="event.stopPropagation();${addImgFn}(this,'${postId}')">
</label>
<button onclick="event.stopPropagation();${saveFn}('${postId}')" style="width:auto;margin:0;">Speichern</button>
<button onclick="event.stopPropagation();${cancelFn}('${postId}')" style="width:auto;margin:0;background:var(--color-secondary);color:var(--color-text);">Abbrechen</button>
</div>`;
const ea = document.getElementById(`${prefix}ea-${postId}`);
ea.style.display = ''; ea.onclick = e => e.stopPropagation();
ea.innerHTML = `<textarea id="${prefix}et-${postId}" style="width:100%;box-sizing:border-box;padding:0.6rem;border:1px solid var(--color-secondary);border-radius:6px;background:var(--color-secondary);color:var(--color-text);font-size:0.95rem;resize:vertical;min-height:70px;" onclick="event.stopPropagation()" onkeydown="event.stopPropagation()">${esc(data.text || '')}</textarea>
<div class="compose-thumbs" id="${prefix}et-tb-${postId}" style="margin-top:0.4rem;"></div>
${optionenHtml}${actionRow}`;
_renderEditThumbs(editBilderMap, postId, prefix, rmImgFn);
}
function cancelPostEdit(postId, prefix, editBilderMap) {
document.getElementById(`${prefix}va-${postId}`).style.display = '';
document.getElementById(`${prefix}um-${postId}`).style.display = '';
document.getElementById(`${prefix}ea-${postId}`).style.display = 'none';
editBilderMap.delete(postId);
}
function _renderEditThumbs(editBilderMap, postId, prefix, rmFn) {
const bilder = editBilderMap.get(postId) || [];
const c = document.getElementById(`${prefix}et-tb-${postId}`);
if (!c) return;
c.innerHTML = bilder.map((b, i) =>
`<div class="compose-thumb"><img src="data:image/jpeg;base64,${b}" alt="">
<button class="compose-thumb-remove" onclick="event.stopPropagation();${rmFn}('${postId}',${i})">✕</button></div>`
).join('');
c.style.display = bilder.length > 0 ? 'flex' : 'none';
}
function editAddOptionRow(containerId) {
const container = document.getElementById(containerId);
if (!container) return;
const count = container.querySelectorAll('input[type=text]').length;
const row = document.createElement('div');
row.className = 'umfrage-option-row';
row.innerHTML = `<input type="text" placeholder="Option ${count + 1}" maxlength="200"
style="flex:1;padding:0.4rem 0.6rem;border:1px solid var(--color-secondary);border-radius:6px;background:var(--color-secondary);color:var(--color-text);font-size:0.9rem;"
onmousedown="event.stopPropagation()" onclick="event.stopPropagation()" onkeydown="event.stopPropagation()">
<button onmousedown="event.stopPropagation()" onclick="event.stopPropagation();this.closest('.umfrage-option-row').remove()" style="width:auto;margin:0;padding:0.3rem 0.6rem;font-size:0.8rem;">✕</button>`;
container.insertBefore(row, container.querySelector('div:last-child'));
}
/**
* cfg: { postId, prefix, endpoint, isUmfrage, editBilderMap, onSuccess }
* onSuccess(updated) Seite aktualisiert Cache und DOM.
*/
async function savePostEdit(cfg) {
const { postId, prefix, endpoint, isUmfrage, editBilderMap, onSuccess } = cfg;
const text = document.getElementById(`${prefix}et-${postId}`).value.trim();
if (!text) return;
const bilder = editBilderMap.get(postId) || [];
const optionen = isUmfrage
? Array.from(document.querySelectorAll(`#${prefix}eo-${postId} input[type=text]`))
.map(inp => ({ optionId: inp.dataset.optionId || null, text: inp.value.trim() }))
.filter(o => o.text)
: null;
const multiChoice = isUmfrage ? (document.getElementById(`${prefix}mc-${postId}`)?.checked ?? false) : null;
const res = await fetch(endpoint, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, bilder, optionen, multiChoice })
});
if (!res.ok) return;
editBilderMap.delete(postId);
onSuccess(await res.json());
}
/** Aktualisiert Text, Bilder, Edit-Area, Umfrage und (bearbeitet)-Label im DOM. */
function applyPostEditDom(postId, prefix, updated, umfrageHtml) {
document.getElementById(`${prefix}va-${postId}`).querySelector('.post-text').innerHTML = renderTextWithHashtags(updated.text);
document.getElementById(`${prefix}bi-${postId}`).innerHTML = bilderGrid(updated.bilder);
document.getElementById(`${prefix}va-${postId}`).style.display = '';
document.getElementById(`${prefix}ea-${postId}`).style.display = 'none';
const pum = document.getElementById(`${prefix}um-${postId}`);
if (pum) { pum.innerHTML = umfrageHtml || ''; pum.style.display = ''; }
const meta = document.getElementById(`${prefix}m-${postId}`);
if (meta && !meta.querySelector('.edited-label')) {
meta.insertAdjacentHTML('beforeend', ' <span class="edited-label" style="font-size:0.75rem;color:var(--color-muted);">(bearbeitet)</span>');
}
}

View File

@@ -7,6 +7,7 @@
<title>Home xXx Sphere</title> <title>Home xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css"> <link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css"> <link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="/css/community.css">
<style> <style>
.game-grid { .game-grid {
display: grid; display: grid;
@@ -184,64 +185,9 @@
} }
.friend-req-badge { font-size: 0.65rem; color: var(--color-primary); font-weight: 600; text-align: center; } .friend-req-badge { font-size: 0.65rem; color: var(--color-primary); font-weight: 600; text-align: center; }
/* ── Compose ── */ /* ── Post-Cards (Home: klickbar + Hover) ── */
.post-compose { background:var(--color-card); border:1px solid var(--color-secondary); border-radius:10px; padding:1rem; margin-bottom:1rem; transition:border-color 0.15s; } .post-card { cursor:pointer; transition:border-color 0.15s; }
.post-compose.drag-over { border-color:var(--color-primary); background:rgba(var(--color-primary-rgb,180,0,60),0.06); }
.compose-type { display:flex; gap:1.5rem; margin-bottom:0.75rem; }
.compose-type label { display:flex; align-items:center; gap:0.4rem; font-size:0.9rem; cursor:pointer; }
.post-compose textarea { width:100%; padding:0.6rem 0.85rem; border:1px solid var(--color-secondary); border-radius:6px; background:var(--color-secondary); color:var(--color-text); font-size:0.95rem; outline:none; transition:border-color 0.2s; resize:vertical; min-height:70px; box-sizing:border-box; }
.post-compose textarea:focus { border-color:var(--color-primary); }
.compose-thumbs { display:none; flex-wrap:wrap; gap:0.5rem; margin-top:0.5rem; }
.compose-thumb { position:relative; width:64px; height:64px; flex-shrink:0; }
.compose-thumb img { width:64px; height:64px; object-fit:cover; border-radius:6px; display:block; }
.compose-thumb-remove { position:absolute; top:-5px; right:-5px; background:rgba(0,0,0,0.7); border:none; color:#fff; border-radius:50%; font-size:0.65rem; cursor:pointer; display:flex; align-items:center; justify-content:center; padding:0; margin:0; width:auto; line-height:1; }
.umfrage-options { margin-top:0.5rem; }
.umfrage-option-row { display:flex; gap:0.5rem; margin-bottom:0.4rem; }
.umfrage-option-row input { flex:1; }
.umfrage-option-row button { width:auto; margin:0; padding:0.3rem 0.6rem; font-size:0.8rem; }
.compose-footer { display:flex; justify-content:space-between; align-items:center; margin-top:0.75rem; flex-wrap:wrap; gap:0.5rem; }
.multi-toggle { font-size:0.85rem; display:flex; align-items:center; gap:0.4rem; }
.privacy-toggle { font-size:0.85rem; display:flex; align-items:center; gap:0.4rem; }
.compose-action-btn { background:none; border:1px solid var(--color-secondary); color:var(--color-muted); border-radius:6px; padding:0.35rem 0.6rem; font-size:0.95rem; cursor:pointer; margin:0; width:auto; transition:border-color 0.15s,color 0.15s; }
.compose-action-btn:hover { border-color:var(--color-primary); color:var(--color-primary); background:none; }
label.compose-action-btn { display:inline-flex; align-items:center; }
/* ── Post Cards (1:1 wie Feed) ── */
.post-card { background:var(--color-card); border:1px solid var(--color-secondary); border-radius:10px; padding:1rem; margin-bottom:0.9rem; cursor:pointer; transition:border-color 0.15s; }
.post-card:hover { border-color:var(--color-primary); } .post-card:hover { border-color:var(--color-primary); }
.post-header { display:flex; align-items:center; gap:0.7rem; margin-bottom:0.6rem; }
.post-avatar { width:36px; height:36px; border-radius:50%; background:var(--color-secondary); display:flex; align-items:center; justify-content:center; font-size:0.95rem; flex-shrink:0; overflow:hidden; }
.post-avatar img { width:100%; height:100%; object-fit:cover; }
.post-author { font-weight:600; font-size:0.9rem; }
.post-meta { font-size:0.75rem; color:var(--color-muted); }
.post-text { font-size:0.95rem; line-height:1.5; white-space:pre-wrap; word-break:break-word; }
.post-bild { width:100%; max-height:400px; object-fit:contain; border-radius:6px; margin-top:0.5rem; display:block; }
.post-actions { display:flex; gap:1rem; margin-top:0.75rem; align-items:center; flex-wrap:wrap; }
.post-action-btn { background:none; border:none; color:var(--color-muted); font-size:0.85rem; padding:0; display:flex; align-items:center; gap:0.3rem; margin:0; width:auto; pointer-events:none; }
.post-action-btn.active { color:var(--color-primary); }
.gruppe-badge { display:inline-flex; align-items:center; gap:0.3rem; font-size:0.75rem; color:var(--color-muted); background:var(--color-secondary); border-radius:4px; padding:0.15rem 0.45rem; margin-left:0.3rem; }
.umfrage-option-bar { margin:0.3rem 0; border-radius:6px; overflow:hidden; border:1px solid var(--color-secondary); position:relative; }
.umfrage-option-bar.voted { border-color:var(--color-primary); }
.umfrage-bar-fill { position:absolute; inset:0; background:rgba(var(--color-primary-rgb,180,0,60),0.15); }
.umfrage-bar-content { position:relative; display:flex; justify-content:space-between; padding:0.45rem 0.75rem; font-size:0.88rem; }
.umfrage-total { font-size:0.78rem; color:var(--color-muted); margin-top:0.3rem; }
/* ── Post Lightbox ── */
.lightbox { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.88); z-index:300; align-items:center; justify-content:center; }
.lightbox.open { display:flex; }
.lb-layout { display:flex; max-width:min(1340px, calc(100vw - 2rem)); width:95vw; height:min(90vh, 1100px); background:var(--color-card); border-radius:12px; overflow:hidden; position:relative; }
.lb-post-side { flex:1; overflow-y:auto; padding:1.25rem; border-right:1px solid var(--color-secondary); min-width:0; }
.lb-post-side .post-bild { max-height:1024px; }
.lb-close { position:absolute; top:0.6rem; right:0.6rem; background:rgba(0,0,0,0.55); border:none; color:#fff; font-size:1.1rem; width:2rem; height:2rem; border-radius:50%; cursor:pointer; z-index:10; display:flex; align-items:center; justify-content:center; padding:0; margin:0; }
.lb-comments-panel { width:300px; flex-shrink:0; display:flex; flex-direction:column; }
.lb-comments-header { font-size:0.78rem; font-weight:600; color:var(--color-muted); text-transform:uppercase; letter-spacing:0.06em; padding:0.7rem 1rem; border-bottom:1px solid var(--color-secondary); flex-shrink:0; }
.lb-comments-list { flex:1; overflow-y:auto; padding:0.75rem; }
.lb-comment-compose { padding:0.75rem; border-top:1px solid var(--color-secondary); display:flex; flex-direction:column; gap:0.5rem; flex-shrink:0; }
.lb-comment-compose textarea { width:100%; font-size:0.85rem; padding:0.35rem 0.6rem; resize:none; background:var(--color-secondary); border:1px solid var(--color-secondary); border-radius:6px; color:var(--color-text); font-family:inherit; outline:none; transition:border-color 0.2s; box-sizing:border-box; }
.lb-comment-compose textarea:focus { border-color:var(--color-primary); }
.lb-comment-compose-actions { display:flex; gap:0.5rem; justify-content:flex-end; }
.lb-comment-compose button { width:auto; margin:0; padding:0.35rem 0.75rem; font-size:0.8rem; }
@media (max-width:650px) { .lb-layout { flex-direction:column; height:95vh; } .lb-post-side { border-right:none; border-bottom:1px solid var(--color-secondary); max-height:55vh; } .lb-comments-panel { width:100%; } }
/* ── Spiel starten ── */ /* ── Spiel starten ── */
.start-game-grid { .start-game-grid {
@@ -413,21 +359,19 @@
<!-- Feed Compose + Vorschau --> <!-- Feed Compose + Vorschau -->
<div class="section-label">Feed 📰</div> <div class="section-label">Feed 📰</div>
<div class="post-compose" id="homeCompose"> <div class="post-compose" id="homeCompose">
<div class="compose-type">
<label><input type="radio" name="homeBeitragTyp" value="TEXT" checked onchange="homeToggleUmfrage()"> Text</label>
<label><input type="radio" name="homeBeitragTyp" value="UMFRAGE" onchange="homeToggleUmfrage()"> Umfrage</label>
</div>
<textarea id="homeComposeText" placeholder="Was möchtest du teilen?" rows="3"></textarea> <textarea id="homeComposeText" placeholder="Was möchtest du teilen?" rows="3"></textarea>
<div class="compose-thumbs" id="homeComposeThumbs"></div> <div class="compose-thumbs" id="homeComposeThumbs"></div>
<div class="umfrage-options" id="homeUmfrageOptions" style="display:none;"> <div class="umfrage-options" id="homeUmfrageOptions" style="display:none;">
<div id="homeOptionList"></div> <div id="homeOptionList"></div>
<button onclick="homeAddOption()" style="width:auto;margin:0;padding:0.3rem 0.75rem;font-size:0.8rem;margin-top:0.4rem;">+ Option</button> <div style="display:flex;justify-content:space-between;align-items:center;margin-top:0.5rem;">
<button onclick="homeAddOption()" style="width:auto;margin:0;padding:0.3rem 0.75rem;font-size:0.8rem;">+ Option</button>
<label class="multi-toggle">
<input type="checkbox" id="homeMultiChoice"> Mehrfachauswahl möglich
</label>
</div>
</div> </div>
<div class="compose-footer"> <div class="compose-footer">
<div style="display:flex;gap:1rem;align-items:center;flex-wrap:wrap;"> <div style="display:flex;gap:1rem;align-items:center;flex-wrap:wrap;">
<label class="multi-toggle" id="homeMultiChoiceRow" style="display:none;">
<input type="checkbox" id="homeMultiChoice"> Multi-Choice
</label>
<label class="privacy-toggle"> <label class="privacy-toggle">
<input type="checkbox" id="homeIsPublic"> Öffentlich <input type="checkbox" id="homeIsPublic"> Öffentlich
</label> </label>
@@ -437,6 +381,7 @@
<label class="compose-action-btn" title="Fotos hinzufügen">📷 <label class="compose-action-btn" title="Fotos hinzufügen">📷
<input type="file" id="homeComposeBildFile" accept="image/*" multiple style="display:none;" onchange="homeSelectBilder(this)"> <input type="file" id="homeComposeBildFile" accept="image/*" multiple style="display:none;" onchange="homeSelectBilder(this)">
</label> </label>
<button type="button" id="homeUmfrageBtn" class="compose-action-btn" onclick="homeToggleUmfrage(this)" title="Umfrage hinzufügen">📊</button>
<button onclick="homeSubmitPost()" style="width:auto;margin:0;">Veröffentlichen</button> <button onclick="homeSubmitPost()" style="width:auto;margin:0;">Veröffentlichen</button>
</div> </div>
</div> </div>
@@ -482,6 +427,7 @@
.then(user => { .then(user => {
if (user) { if (user) {
myUserId = user.userId; myUserId = user.userId;
initLb(user.userId);
document.getElementById('greeting').textContent = 'Willkommen zurück, ' + user.name + '!'; document.getElementById('greeting').textContent = 'Willkommen zurück, ' + user.name + '!';
Promise.all([loadActiveGames(user.userId), loadActiveLock()]).then(() => { Promise.all([loadActiveGames(user.userId), loadActiveLock()]).then(() => {
const hasGames = document.getElementById('activeGamesSection').style.display !== 'none'; const hasGames = document.getElementById('activeGamesSection').style.display !== 'none';
@@ -770,15 +716,22 @@
let homeComposeBilder = []; let homeComposeBilder = [];
function homeToggleUmfrage() { function homeToggleUmfrage(btn) {
const isUmfrage = document.querySelector('input[name="homeBeitragTyp"]:checked').value === 'UMFRAGE'; const options = document.getElementById('homeUmfrageOptions');
document.getElementById('homeUmfrageOptions').style.display = isUmfrage ? '' : 'none'; const isShowing = options.style.display !== 'none';
document.getElementById('homeMultiChoiceRow').style.display = isUmfrage ? '' : 'none'; options.style.display = isShowing ? 'none' : '';
if (isUmfrage && document.getElementById('homeOptionList').children.length === 0) { if (btn) btn.classList.toggle('active', !isShowing);
if (!isShowing && document.getElementById('homeOptionList').children.length === 0) {
homeAddOption(); homeAddOption(); homeAddOption(); homeAddOption();
} }
} }
function homeResetUmfrage() {
document.getElementById('homeUmfrageOptions').style.display = 'none';
document.getElementById('homeOptionList').innerHTML = '';
document.getElementById('homeUmfrageBtn').classList.remove('active');
}
function homeAddOption() { function homeAddOption() {
const list = document.getElementById('homeOptionList'); const list = document.getElementById('homeOptionList');
const idx = list.children.length; const idx = list.children.length;
@@ -789,57 +742,28 @@
list.appendChild(row); list.appendChild(row);
} }
function homeSelectBilder(input) {
[...input.files].forEach(f => { if (f.type.startsWith('image/')) homeProcessImage(f); });
input.value = '';
}
function homeProcessImage(file) {
const reader = new FileReader();
reader.onload = e => {
const img = new Image();
img.onload = () => {
const maxSize = 1024;
const canvas = document.createElement('canvas');
const scale = Math.min(maxSize / img.width, maxSize / img.height, 1);
canvas.width = Math.round(img.width * scale);
canvas.height = Math.round(img.height * scale);
canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height);
homeComposeBilder.push(canvas.toDataURL('image/jpeg', 0.85).split(',')[1]);
homeRenderThumbs();
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
}
function homeRenderThumbs() { function homeRenderThumbs() {
const container = document.getElementById('homeComposeThumbs'); renderBilderThumbs(homeComposeBilder, 'homeComposeThumbs', i => {
container.innerHTML = ''; homeComposeBilder.splice(i, 1);
homeComposeBilder.forEach((b, i) => { homeRenderThumbs();
const div = document.createElement('div');
div.className = 'compose-thumb';
div.innerHTML = `<img src="data:image/jpeg;base64,${b}" alt="">
<button class="compose-thumb-remove" onclick="homeRemoveThumb(${i})">✕</button>`;
container.appendChild(div);
}); });
container.style.display = homeComposeBilder.length > 0 ? 'flex' : 'none';
} }
function homeRemoveThumb(idx) { function homeSelectBilder(input) {
homeComposeBilder.splice(idx, 1); [...input.files].forEach(f => processImageFile(f, homeComposeBilder, homeRenderThumbs));
homeRenderThumbs(); input.value = '';
} }
async function homeSubmitPost() { async function homeSubmitPost() {
const text = document.getElementById('homeComposeText').value.trim(); const text = document.getElementById('homeComposeText').value.trim();
const hasUmfrage = document.getElementById('homeUmfrageOptions').style.display !== 'none';
if (!text && homeComposeBilder.length === 0) return; if (!text && homeComposeBilder.length === 0) return;
const beitragTyp = document.querySelector('input[name="homeBeitragTyp"]:checked').value; const beitragTyp = hasUmfrage ? 'UMFRAGE' : 'TEXT';
const multiChoice = document.getElementById('homeMultiChoice').checked; const multiChoice = document.getElementById('homeMultiChoice').checked;
const isPublic = document.getElementById('homeIsPublic').checked; const isPublic = document.getElementById('homeIsPublic').checked;
let optionen = []; let optionen = [];
if (beitragTyp === 'UMFRAGE') { if (hasUmfrage) {
optionen = Array.from(document.getElementById('homeOptionList').querySelectorAll('input')) optionen = Array.from(document.getElementById('homeOptionList').querySelectorAll('input'))
.map(i => i.value.trim()).filter(v => v); .map(i => i.value.trim()).filter(v => v);
if (optionen.length < 2) { alert('Mindestens 2 Optionen erforderlich.'); return; } if (optionen.length < 2) { alert('Mindestens 2 Optionen erforderlich.'); return; }
@@ -856,11 +780,9 @@
document.getElementById('homeComposeText').value = ''; document.getElementById('homeComposeText').value = '';
homeComposeBilder = []; homeComposeBilder = [];
homeRenderThumbs(); homeRenderThumbs();
document.querySelector('input[name="homeBeitragTyp"][value="TEXT"]').checked = true; homeResetUmfrage();
homeToggleUmfrage();
document.getElementById('homeMultiChoice').checked = false; document.getElementById('homeMultiChoice').checked = false;
document.getElementById('homeIsPublic').checked = false; document.getElementById('homeIsPublic').checked = false;
document.getElementById('homeOptionList').innerHTML = '';
// Prepend in Vorschau // Prepend in Vorschau
const feedList = document.getElementById('feedList'); const feedList = document.getElementById('feedList');
@@ -882,7 +804,7 @@
homeCompose.addEventListener('drop', e => { homeCompose.addEventListener('drop', e => {
e.preventDefault(); e.preventDefault();
homeCompose.classList.remove('drag-over'); homeCompose.classList.remove('drag-over');
[...e.dataTransfer.files].filter(f => f.type.startsWith('image/')).forEach(homeProcessImage); [...e.dataTransfer.files].filter(f => f.type.startsWith('image/')).forEach(f => processImageFile(f, homeComposeBilder, homeRenderThumbs));
}); });
} }
@@ -890,69 +812,49 @@
const homePostCache = {}; const homePostCache = {};
let activeLbPostId = null;
let activeLbPostType = null;
function homeOpenPost(postId) { function homeOpenPost(postId) {
const p = homePostCache[postId]; const p = homePostCache[postId];
if (!p) return; if (!p) return;
activeLbPostId = p.postId;
activeLbPostType = p.postType || 'FEED';
const tempDiv = document.createElement('div'); const tempDiv = document.createElement('div');
tempDiv.innerHTML = renderHomePostCard(p); tempDiv.innerHTML = renderHomePostCard(p);
const card = tempDiv.firstElementChild; const card = tempDiv.firstElementChild;
if (card) { if (card) {
card.querySelectorAll('.post-actions').forEach(el => el.remove()); card.querySelectorAll('.post-actions').forEach(el => el.remove());
document.getElementById('lbPostBody').innerHTML = card.innerHTML; document.getElementById('lbPostBody').innerHTML = card.innerHTML;
_lbSetupContent(postId, 'hp', p.bilder);
} }
loadLbComments(p.postId, activeLbPostType); loadLbComments(p.postId, p.postType || 'FEED');
document.getElementById('postLightbox').classList.add('open'); document.getElementById('postLightbox').classList.add('open');
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';
} }
function closeLb() {
document.getElementById('postLightbox').classList.remove('open');
document.body.style.overflow = '';
activeLbPostId = null;
activeLbPostType = null;
}
document.getElementById('postLightbox').addEventListener('click', e => { document.getElementById('postLightbox').addEventListener('click', e => {
if (e.target === document.getElementById('postLightbox')) closeLb(); if (e.target === document.getElementById('postLightbox')) closeLb();
}); });
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && document.getElementById('postLightbox').classList.contains('open')) closeLb();
});
async function loadLbComments(postId, postType) { // ── Like / Delete ──────────────────────────────────────────────────────────
const targetType = postType === 'GROUP' ? 'GROUP_POST' : 'FEED_POST';
try { async function likeHomePost(postId, postType) {
const res = await fetch(`/social/kommentare?targetType=${targetType}&targetId=${postId}`); const ep = postType === 'GROUP'
const comments = await res.json(); ? `/gruppen/${document.getElementById('hpc-'+postId)?.dataset?.gruppeId}/posts/${postId}/like`
document.getElementById('lbCommentsList').innerHTML = comments.length === 0 : `/feed/posts/${postId}/like`;
? '<p style="color:var(--color-muted);font-size:0.82rem;margin:0.4rem;">Noch keine Kommentare.</p>' await fetch(ep, { method: 'POST' });
: comments.map(k => renderKommentarHtml(k, targetType, postId, { myUserId })).join(''); const btn = document.getElementById('hlk-' + postId);
} catch (_) {} const lc = document.getElementById('hlkc-' + postId);
const was = btn.classList.contains('active');
btn.classList.toggle('active', !was);
lc.textContent = parseInt(lc.textContent) + (was ? -1 : 1);
} }
async function postLbComment() { async function deleteHomePost(postId) {
if (!activeLbPostId) return; if (!confirm('Post löschen?')) return;
const input = document.getElementById('lbCommentInput'); const res = await fetch('/feed/posts/' + postId, { method: 'DELETE' });
const text = input.value.trim(); if (res.ok) document.getElementById('hpc-' + postId)?.remove();
if (!text) return;
const targetType = activeLbPostType === 'GROUP' ? 'GROUP_POST' : 'FEED_POST';
await fetch('/social/kommentare', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetType, targetId: activeLbPostId, text })
});
input.value = '';
await loadLbComments(activeLbPostId, activeLbPostType);
} }
async function deleteKommentar(kommentarId, targetType, targetId) { // ── Post-Karte ────────────────────────────────────────────────────────────
await fetch('/social/kommentare/' + kommentarId, { method: 'DELETE' });
await loadLbComments(targetId, activeLbPostType); const homeEditBilder = new Map();
}
function renderHomePostCard(p) { function renderHomePostCard(p) {
homePostCache[p.postId] = p; homePostCache[p.postId] = p;
@@ -963,7 +865,8 @@
const groupBadge = p.postType === 'GROUP' && p.gruppeId const groupBadge = p.postType === 'GROUP' && p.gruppeId
? `<span class="gruppe-badge">👥 ${esc(p.gruppeName)}</span>` ? `<span class="gruppe-badge">👥 ${esc(p.gruppeName)}</span>`
: ''; : '';
const bildHtml = bilderCarousel(p.bilder); const bildHtml = bilderGrid(p.bilder);
const editedLabel = p.editedAt ? ` <span style="font-size:0.75rem;color:var(--color-muted);">(bearbeitet)</span>` : '';
let umfrageHtml = ''; let umfrageHtml = '';
if (p.beitragTyp === 'UMFRAGE' && p.optionen && p.optionen.length > 0) { if (p.beitragTyp === 'UMFRAGE' && p.optionen && p.optionen.length > 0) {
const totalVotes = p.optionen.reduce((s, o) => s + o.stimmenCount, 0); const totalVotes = p.optionen.reduce((s, o) => s + o.stimmenCount, 0);
@@ -976,24 +879,74 @@
</div>`; </div>`;
}).join('') + `<div class="umfrage-total">${totalVotes} Stimme${totalVotes !== 1 ? 'n' : ''}</div></div>`; }).join('') + `<div class="umfrage-total">${totalVotes} Stimme${totalVotes !== 1 ? 'n' : ''}</div></div>`;
} }
return `<div class="post-card" onclick="homeOpenPost('${p.postId}')" style="cursor:pointer"> const canOwn = p.postType === 'FEED' && p.authorId === myUserId;
const ownBtns = canOwn
? `<div style="margin-left:auto;display:flex;gap:0.4rem;">
<button class="post-action-btn" onclick="event.stopPropagation();startHomeEdit('${p.postId}')" title="Bearbeiten" style="color:var(--color-muted)">✏</button>
<button class="post-action-btn post-delete" onclick="event.stopPropagation();deleteHomePost('${p.postId}')" title="Löschen">🗑</button>
</div>`
: '';
const gruppeIdAttr = p.gruppeId ? ` data-gruppe-id="${p.gruppeId}"` : '';
return `<div class="post-card" id="hpc-${p.postId}"${gruppeIdAttr} onclick="homeOpenPost('${p.postId}')">
<div class="post-header"> <div class="post-header">
<div class="post-avatar">${avatarHtml}</div> <div class="post-avatar">${avatarHtml}</div>
<div> <div>
<div class="post-author">${esc(p.authorName)}${privacyLabel}</div> <div class="post-author">${esc(p.authorName)}${privacyLabel}</div>
<div class="post-meta">${fmtDate(p.createdAt)}${groupBadge}</div> <div class="post-meta" id="hpm-${p.postId}">${fmtDate(p.createdAt)}${editedLabel}${groupBadge}</div>
</div> </div>
${ownBtns}
</div> </div>
<div id="hpva-${p.postId}">
<div class="post-text">${renderTextWithHashtags(p.text || '')}</div> <div class="post-text">${renderTextWithHashtags(p.text || '')}</div>
${bildHtml} <div id="hpbi-${p.postId}">${bildHtml}</div>
${umfrageHtml} </div>
<div id="hpea-${p.postId}" style="display:none;"></div>
<div id="hpum-${p.postId}">${umfrageHtml}</div>
<div class="post-actions"> <div class="post-actions">
<button class="post-action-btn${p.likedByMe ? ' active' : ''}">♥ <span>${p.likeCount}</span></button> <button class="post-action-btn${p.likedByMe ? ' active' : ''}" id="hlk-${p.postId}" onclick="event.stopPropagation();likeHomePost('${p.postId}','${p.postType}')">♥ <span id="hlkc-${p.postId}">${p.likeCount}</span></button>
<button class="post-action-btn">💬 <span>${p.kommentarCount}</span></button> <button class="post-action-btn" onclick="event.stopPropagation();homeOpenPost('${p.postId}')">💬 <span id="hkc-${p.postId}">${p.kommentarCount}</span></button>
</div> </div>
</div>`; </div>`;
} }
// ── Post-Bearbeitung (Home) ───────────────────────────────────────────────
function startHomeEdit(postId) {
const data = homePostCache[postId];
if (!data) return;
startPostEdit({ postId, prefix: 'hp', data, editBilderMap: homeEditBilder,
saveFn: 'saveHomeEdit', cancelFn: 'cancelHomeEdit',
addImgFn: 'homeEditAddImg', addOptionFn: 'homeEditAddOption', rmImgFn: 'homeEditRmImg' });
}
function cancelHomeEdit(postId) { cancelPostEdit(postId, 'hp', homeEditBilder); }
function homeEditRmImg(postId, idx) {
homeEditBilder.get(postId).splice(idx, 1);
_renderEditThumbs(homeEditBilder, postId, 'hp', 'homeEditRmImg');
}
function homeEditAddImg(input, postId) {
[...input.files].forEach(f => processImageFile(f, homeEditBilder.get(postId), () => _renderEditThumbs(homeEditBilder, postId, 'hp', 'homeEditRmImg')));
input.value = '';
}
function homeEditAddOption(postId) { editAddOptionRow(`hpeo-${postId}`); }
async function saveHomeEdit(postId) {
const cached = homePostCache[postId];
await savePostEdit({ postId, prefix: 'hp', endpoint: `/feed/posts/${postId}`,
isUmfrage: cached?.beitragTyp === 'UMFRAGE', editBilderMap: homeEditBilder,
onSuccess: updated => {
homePostCache[postId] = { ...cached, text: updated.text, bilder: updated.bilder || [], optionen: updated.optionen || [], multiChoice: updated.multiChoice };
const totalVotes = (updated.optionen || []).reduce((s, o) => s + o.stimmenCount, 0);
const umfrageHtml = updated.optionen?.length > 0
? '<div style="margin-top:0.5rem;">' + updated.optionen.map(o => {
const pct = totalVotes > 0 ? Math.round(o.stimmenCount / totalVotes * 100) : 0;
return `<div class="umfrage-option-bar"><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>`
: '';
applyPostEditDom(postId, 'hp', updated, umfrageHtml);
}
});
}
async function loadFeed() { async function loadFeed() {
try { try {
const res = await fetch('/feed/mine?size=3&page=0'); const res = await fetch('/feed/mine?size=3&page=0');

View File

@@ -17,6 +17,7 @@ import org.springframework.data.domain.Slice;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
@@ -27,6 +28,7 @@ import org.springframework.web.bind.annotation.RestController;
import de.oaa.xxx.feed.dto.FeedItemDto; import de.oaa.xxx.feed.dto.FeedItemDto;
import de.oaa.xxx.feed.dto.FeedPostRequest; import de.oaa.xxx.feed.dto.FeedPostRequest;
import de.oaa.xxx.feed.entity.FeedPostEntity; import de.oaa.xxx.feed.entity.FeedPostEntity;
import de.oaa.xxx.feed.entity.PosterType;
import de.oaa.xxx.hashtag.HashtagService; import de.oaa.xxx.hashtag.HashtagService;
import de.oaa.xxx.hashtag.PostHashtagEntity; import de.oaa.xxx.hashtag.PostHashtagEntity;
import de.oaa.xxx.feed.entity.FeedPostOptionEntity; import de.oaa.xxx.feed.entity.FeedPostOptionEntity;
@@ -46,6 +48,11 @@ import de.oaa.xxx.gruppe.repository.GruppenbeitragRepository;
import de.oaa.xxx.gruppe.repository.GruppenmitgliedRepository; import de.oaa.xxx.gruppe.repository.GruppenmitgliedRepository;
import de.oaa.xxx.gruppe.repository.UmfrageOptionRepository; import de.oaa.xxx.gruppe.repository.UmfrageOptionRepository;
import de.oaa.xxx.gruppe.repository.UmfrageStimmeRepository; import de.oaa.xxx.gruppe.repository.UmfrageStimmeRepository;
import de.oaa.xxx.location.entity.LocationEntity;
import de.oaa.xxx.location.entity.LocationFollowEntity;
import de.oaa.xxx.location.repository.LocationAdminRepository;
import de.oaa.xxx.location.repository.LocationFollowRepository;
import de.oaa.xxx.location.repository.LocationRepository;
import de.oaa.xxx.social.LikeService; import de.oaa.xxx.social.LikeService;
import de.oaa.xxx.social.entity.FriendshipEntity; import de.oaa.xxx.social.entity.FriendshipEntity;
import de.oaa.xxx.social.repository.FriendshipRepository; import de.oaa.xxx.social.repository.FriendshipRepository;
@@ -76,6 +83,9 @@ public class FeedController {
private final UserService userService; private final UserService userService;
private final LikeService likeService; private final LikeService likeService;
private final HashtagService hashtagService; private final HashtagService hashtagService;
private final LocationRepository locationRepository;
private final LocationFollowRepository locationFollowRepository;
private final LocationAdminRepository locationAdminRepository;
public FeedController(FeedPostRepository feedPostRepository, public FeedController(FeedPostRepository feedPostRepository,
FeedPostLikeRepository feedPostLikeRepository, FeedPostLikeRepository feedPostLikeRepository,
@@ -92,7 +102,10 @@ public class FeedController {
UserRepository userRepository, UserRepository userRepository,
UserService userService, UserService userService,
LikeService likeService, LikeService likeService,
HashtagService hashtagService) { HashtagService hashtagService,
LocationRepository locationRepository,
LocationFollowRepository locationFollowRepository,
LocationAdminRepository locationAdminRepository) {
this.feedPostRepository = feedPostRepository; this.feedPostRepository = feedPostRepository;
this.feedPostLikeRepository = feedPostLikeRepository; this.feedPostLikeRepository = feedPostLikeRepository;
this.feedPostOptionRepository = feedPostOptionRepository; this.feedPostOptionRepository = feedPostOptionRepository;
@@ -109,10 +122,15 @@ public class FeedController {
this.userService = userService; this.userService = userService;
this.likeService = likeService; this.likeService = likeService;
this.hashtagService = hashtagService; this.hashtagService = hashtagService;
this.locationRepository = locationRepository;
this.locationFollowRepository = locationFollowRepository;
this.locationAdminRepository = locationAdminRepository;
} }
record FeedPage(List<FeedItemDto> posts, boolean hasMore) {} record FeedPage(List<FeedItemDto> posts, boolean hasMore) {}
record VoteRequest(UUID optionId) {} record VoteRequest(UUID optionId) {}
record UpdateOptionRequest(UUID optionId, String text) {}
record UpdatePostRequest(String text, List<String> bilder, List<UpdateOptionRequest> optionen, Boolean multiChoice) {}
// ── POST /feed/posts ── // ── POST /feed/posts ──
@@ -132,6 +150,7 @@ public class FeedController {
FeedPostEntity post = new FeedPostEntity(); FeedPostEntity post = new FeedPostEntity();
post.setPostId(UUID.randomUUID()); post.setPostId(UUID.randomUUID());
post.setAuthorId(myId); post.setAuthorId(myId);
post.setPosterType(PosterType.USER);
post.setText(req.text().trim()); post.setText(req.text().trim());
post.setBeitragTyp(typ); post.setBeitragTyp(typ);
post.setMultiChoice(typ == BeitragTyp.UMFRAGE ? req.multiChoice() : null); post.setMultiChoice(typ == BeitragTyp.UMFRAGE ? req.multiChoice() : null);
@@ -158,6 +177,61 @@ public class FeedController {
return ResponseEntity.status(201).body(toFeedItemDtoFromPost(post, myId)); return ResponseEntity.status(201).body(toFeedItemDtoFromPost(post, myId));
} }
// ── POST /feed/location/{locationId}/posts ──
@PostMapping("/location/{locationId}/posts")
public ResponseEntity<FeedItemDto> createLocationPost(@PathVariable("locationId") UUID locationId,
@RequestBody FeedPostRequest req,
Principal principal) {
UUID myId = resolveMyId(principal);
if (myId == null) return ResponseEntity.status(401).build();
var locOpt = locationRepository.findById(locationId);
if (locOpt.isEmpty()) return ResponseEntity.notFound().build();
boolean isAdmin = locOpt.get().getOwnerId().equals(myId)
|| locationAdminRepository.existsByLocationIdAndUserId(locationId, myId);
if (!isAdmin) return ResponseEntity.status(403).build();
if (req.text() == null || req.text().isBlank()) return ResponseEntity.badRequest().build();
BeitragTyp typ;
try {
typ = BeitragTyp.valueOf(req.beitragTyp());
} catch (Exception e) {
return ResponseEntity.badRequest().build();
}
FeedPostEntity post = new FeedPostEntity();
post.setPostId(UUID.randomUUID());
post.setAuthorId(locationId);
post.setPosterType(PosterType.LOCATION);
post.setText(req.text().trim());
post.setBeitragTyp(typ);
post.setMultiChoice(typ == BeitragTyp.UMFRAGE ? req.multiChoice() : null);
post.setBilder(req.bilder() != null ? req.bilder() : List.of());
post.setPublic(true);
post.setCreatedAt(LocalDateTime.now());
feedPostRepository.save(post);
hashtagService.saveForPost(post.getText(), "FEED", post.getPostId(), post.getCreatedAt());
LOGGER.info("Location {} hat Feed-Post {} erstellt (Typ: {})", locationId, post.getPostId(), typ);
if (typ == BeitragTyp.UMFRAGE && req.optionen() != null) {
for (int i = 0; i < req.optionen().size(); i++) {
String optText = req.optionen().get(i);
if (optText == null || optText.isBlank()) continue;
FeedPostOptionEntity opt = new FeedPostOptionEntity();
opt.setOptionId(UUID.randomUUID());
opt.setPostId(post.getPostId());
opt.setText(optText.trim());
opt.setReihenfolge(i);
feedPostOptionRepository.save(opt);
}
}
return ResponseEntity.status(201).body(toFeedItemDtoFromPost(post, myId));
}
// ── GET /feed/mine ── // ── GET /feed/mine ──
@GetMapping("/mine") @GetMapping("/mine")
@@ -183,9 +257,15 @@ public class FeedController {
.map(m -> m.getGruppeId()) .map(m -> m.getGruppeId())
.toList(); .toList();
// Collect followed location IDs
List<UUID> followedLocationIds = locationFollowRepository.findByUserId(myId)
.stream()
.map(LocationFollowEntity::getLocationId)
.toList();
LocalDateTime since = LocalDateTime.now().minusDays(90); LocalDateTime since = LocalDateTime.now().minusDays(90);
// Fetch feed posts from friends + self // Fetch feed posts from friends + self (USER type)
List<FeedPostEntity> feedPosts = feedPostRepository List<FeedPostEntity> feedPosts = feedPostRepository
.findByAuthorIdInAndCreatedAtAfterOrderByCreatedAtDesc(authorIds, since); .findByAuthorIdInAndCreatedAtAfterOrderByCreatedAtDesc(authorIds, since);
@@ -193,10 +273,18 @@ public class FeedController {
List<GruppenbeitragEntity> gruppePosts = gruppeIds.isEmpty() ? List.of() : List<GruppenbeitragEntity> gruppePosts = gruppeIds.isEmpty() ? List.of() :
gruppenbeitragRepository.findByGruppeIdInAndCreatedAtAfterOrderByCreatedAtDesc(gruppeIds, since); gruppenbeitragRepository.findByGruppeIdInAndCreatedAtAfterOrderByCreatedAtDesc(gruppeIds, since);
// Fetch location posts from followed locations
List<FeedPostEntity> locationPosts = followedLocationIds.isEmpty() ? List.of() :
feedPostRepository.findByAuthorIdInAndPosterTypeAndCreatedAtAfterOrderByCreatedAtDesc(
followedLocationIds, PosterType.LOCATION, since);
// Merge, convert, sort // Merge, convert, sort
List<FeedItemDto> merged = Stream.concat( List<FeedItemDto> merged = Stream.concat(
Stream.concat(
feedPosts.stream().map(p -> toFeedItemDtoFromPost(p, myId)), feedPosts.stream().map(p -> toFeedItemDtoFromPost(p, myId)),
gruppePosts.stream().map(b -> toFeedItemDtoFromGruppe(b, myId)) gruppePosts.stream().map(b -> toFeedItemDtoFromGruppe(b, myId))
),
locationPosts.stream().map(p -> toFeedItemDtoFromPost(p, myId))
).sorted(Comparator.comparing(FeedItemDto::createdAt).reversed()).toList(); ).sorted(Comparator.comparing(FeedItemDto::createdAt).reversed()).toList();
int from = page * size; int from = page * size;
@@ -260,6 +348,32 @@ public class FeedController {
return ResponseEntity.ok(new FeedPage(items, !nextPage.isEmpty())); return ResponseEntity.ok(new FeedPage(items, !nextPage.isEmpty()));
} }
// ── GET /feed/location/{locationId} ──
@GetMapping("/location/{locationId}")
public ResponseEntity<FeedPage> getLocationFeed(@PathVariable("locationId") UUID locationId,
@RequestParam(name = "page", defaultValue = "0") int page,
@RequestParam(name = "size", defaultValue = "10") int size,
Principal principal) {
UUID myId = resolveMyId(principal);
if (myId == null) return ResponseEntity.status(401).build();
if (!locationRepository.existsById(locationId)) return ResponseEntity.notFound().build();
PageRequest pageable = PageRequest.of(page, size);
List<FeedPostEntity> posts = feedPostRepository
.findByAuthorIdAndPosterTypeOrderByCreatedAtDesc(locationId, PosterType.LOCATION, pageable);
PageRequest nextPageable = PageRequest.of(page + 1, size);
List<FeedPostEntity> nextPage = feedPostRepository
.findByAuthorIdAndPosterTypeOrderByCreatedAtDesc(locationId, PosterType.LOCATION, nextPageable);
List<FeedItemDto> items = posts.stream()
.map(p -> toFeedItemDtoFromPost(p, myId))
.toList();
return ResponseEntity.ok(new FeedPage(items, !nextPage.isEmpty()));
}
// ── GET /feed/hashtag?tag= ── // ── GET /feed/hashtag?tag= ──
@GetMapping("/hashtag") @GetMapping("/hashtag")
@@ -287,9 +401,11 @@ public class FeedController {
for (PostHashtagEntity ref : refs) { for (PostHashtagEntity ref : refs) {
if ("FEED".equals(ref.getPostType())) { if ("FEED".equals(ref.getPostType())) {
feedPostRepository.findById(ref.getPostId()).ifPresent(p -> { feedPostRepository.findById(ref.getPostId()).ifPresent(p -> {
PosterType pt = p.getPosterType() != null ? p.getPosterType() : PosterType.USER;
boolean visible = p.isPublic() boolean visible = p.isPublic()
|| p.getAuthorId().equals(myId) || p.getAuthorId().equals(myId)
|| friendIds.contains(p.getAuthorId()); || friendIds.contains(p.getAuthorId())
|| pt == PosterType.LOCATION;
if (visible) all.add(toFeedItemDtoFromPost(p, myId)); if (visible) all.add(toFeedItemDtoFromPost(p, myId));
}); });
} else if ("GROUP".equals(ref.getPostType())) { } else if ("GROUP".equals(ref.getPostType())) {
@@ -361,6 +477,73 @@ public class FeedController {
return ResponseEntity.ok().build(); return ResponseEntity.ok().build();
} }
// ── PUT /feed/posts/{id} ──
@PutMapping("/posts/{id}")
public ResponseEntity<FeedItemDto> updatePost(@PathVariable("id") UUID id,
@RequestBody UpdatePostRequest req,
Principal principal) {
UUID myId = resolveMyId(principal);
if (myId == null) return ResponseEntity.status(401).build();
if (req.text() == null || req.text().isBlank()) return ResponseEntity.badRequest().build();
var postOpt = feedPostRepository.findById(id);
if (postOpt.isEmpty()) return ResponseEntity.notFound().build();
FeedPostEntity post = postOpt.get();
if (!canModifyPost(post, myId)) return ResponseEntity.status(403).build();
hashtagService.deleteForPost("FEED", id);
post.setText(req.text().trim());
post.setBilder(req.bilder() != null ? req.bilder() : List.of());
post.setEditedAt(LocalDateTime.now());
if (post.getBeitragTyp() == BeitragTyp.UMFRAGE && req.multiChoice() != null) {
post.setMultiChoice(req.multiChoice());
}
feedPostRepository.save(post);
hashtagService.saveForPost(post.getText(), "FEED", post.getPostId(), post.getCreatedAt());
if (req.optionen() != null && post.getBeitragTyp() == BeitragTyp.UMFRAGE) {
List<FeedPostOptionEntity> existingOpts = feedPostOptionRepository.findByPostIdOrderByReihenfolge(id);
Set<UUID> requestedIds = req.optionen().stream()
.filter(o -> o.optionId() != null)
.map(UpdateOptionRequest::optionId)
.collect(Collectors.toSet());
for (FeedPostOptionEntity existing : existingOpts) {
if (!requestedIds.contains(existing.getOptionId())) {
feedPostVoteRepository.deleteByOptionId(existing.getOptionId());
feedPostOptionRepository.delete(existing);
}
}
int reihenfolge = 0;
for (var optReq : req.optionen()) {
if (optReq.text() == null || optReq.text().isBlank()) continue;
final int r = reihenfolge++;
if (optReq.optionId() != null) {
feedPostOptionRepository.findById(optReq.optionId()).ifPresent(opt -> {
if (opt.getPostId().equals(id)) {
opt.setText(optReq.text().trim());
opt.setReihenfolge(r);
feedPostOptionRepository.save(opt);
}
});
} else {
FeedPostOptionEntity newOpt = new FeedPostOptionEntity();
newOpt.setOptionId(UUID.randomUUID());
newOpt.setPostId(id);
newOpt.setText(optReq.text().trim());
newOpt.setReihenfolge(r);
feedPostOptionRepository.save(newOpt);
}
}
}
LOGGER.info("User {} hat Feed-Post {} bearbeitet", myId, id);
return ResponseEntity.ok(toFeedItemDtoFromPost(post, myId));
}
// ── DELETE /feed/posts/{id} ── // ── DELETE /feed/posts/{id} ──
@DeleteMapping("/posts/{id}") @DeleteMapping("/posts/{id}")
@@ -372,7 +555,7 @@ public class FeedController {
if (postOpt.isEmpty()) return ResponseEntity.notFound().build(); if (postOpt.isEmpty()) return ResponseEntity.notFound().build();
FeedPostEntity post = postOpt.get(); FeedPostEntity post = postOpt.get();
if (!post.getAuthorId().equals(myId)) return ResponseEntity.status(403).build(); if (!canModifyPost(post, myId)) return ResponseEntity.status(403).build();
hashtagService.deleteForPost("FEED", id); hashtagService.deleteForPost("FEED", id);
feedPostVoteRepository.deleteByPostId(id); feedPostVoteRepository.deleteByPostId(id);
@@ -388,13 +571,43 @@ public class FeedController {
// ── Helpers ── // ── Helpers ──
private boolean canModifyPost(FeedPostEntity post, UUID myId) {
if (post.getAuthorId().equals(myId)) return true;
PosterType pt = post.getPosterType() != null ? post.getPosterType() : PosterType.USER;
if (pt == PosterType.LOCATION) {
return locationRepository.findById(post.getAuthorId())
.map(l -> l.getOwnerId().equals(myId)
|| locationAdminRepository.existsByLocationIdAndUserId(post.getAuthorId(), myId))
.orElse(false);
}
return false;
}
private UUID resolveMyId(Principal principal) { private UUID resolveMyId(Principal principal) {
if (principal == null) return null; if (principal == null) return null;
return userService.requireUser(principal).getUserId(); return userService.requireUser(principal).getUserId();
} }
private FeedItemDto toFeedItemDtoFromPost(FeedPostEntity p, UUID myId) { private FeedItemDto toFeedItemDtoFromPost(FeedPostEntity p, UUID myId) {
PosterType pt = p.getPosterType() != null ? p.getPosterType() : PosterType.USER;
String authorName;
String authorPicture;
UUID locationId = null;
String locationName = null;
if (pt == PosterType.LOCATION) {
LocationEntity loc = locationRepository.findById(p.getAuthorId()).orElse(null);
authorName = loc != null ? loc.getName() : "Unbekannt";
authorPicture = loc != null ? loc.getProfilePictureLq() : null;
locationId = p.getAuthorId();
locationName = authorName;
} else {
UserEntity author = userRepository.findById(p.getAuthorId()).orElse(null); UserEntity author = userRepository.findById(p.getAuthorId()).orElse(null);
authorName = author != null ? author.getName() : "Unbekannt";
authorPicture = author != null ? author.getProfilePicture() : null;
}
long likeCount = feedPostLikeRepository.countByPostId(p.getPostId()); long likeCount = feedPostLikeRepository.countByPostId(p.getPostId());
boolean likedByMe = feedPostLikeRepository.findByPostIdAndUserId(p.getPostId(), myId).isPresent(); boolean likedByMe = feedPostLikeRepository.findByPostIdAndUserId(p.getPostId(), myId).isPresent();
long kommentarCount = kommentarRepository.countByTargetTypeAndTargetId("FEED_POST", p.getPostId()); long kommentarCount = kommentarRepository.countByTargetTypeAndTargetId("FEED_POST", p.getPostId());
@@ -417,14 +630,18 @@ public class FeedController {
p.getPostId(), "FEED", p.getPostId(), "FEED",
null, null, null, null,
p.getAuthorId(), p.getAuthorId(),
author != null ? author.getName() : "Unbekannt", authorName,
author != null ? author.getProfilePicture() : null, authorPicture,
p.getBeitragTyp().name(), p.getText(), p.getMultiChoice(), p.getBilder(), p.getBeitragTyp().name(), p.getText(), p.getMultiChoice(), p.getBilder(),
p.getCreatedAt(), p.getCreatedAt(),
likeCount, likedByMe, kommentarCount, likeCount, likedByMe, kommentarCount,
optionen, myVoteOptionIds, optionen, myVoteOptionIds,
p.isPublic(), p.isPublic(),
p.getTargetUrl() p.getTargetUrl(),
p.getEditedAt(),
pt.name(),
locationId,
locationName
); );
} }
@@ -462,6 +679,10 @@ public class FeedController {
likeCount, likedByMe, kommentarCount, likeCount, likedByMe, kommentarCount,
optionen, myVoteOptionIds, optionen, myVoteOptionIds,
false, false,
null,
b.getEditedAt(),
"USER",
null,
null null
); );
} }

View File

@@ -25,5 +25,9 @@ public record FeedItemDto(
List<UmfrageOptionDto> optionen, List<UmfrageOptionDto> optionen,
List<UUID> myVoteOptionIds, List<UUID> myVoteOptionIds,
boolean isPublic, boolean isPublic,
String targetUrl String targetUrl,
LocalDateTime editedAt,
String posterType, // "USER" | "LOCATION"
UUID locationId,
String locationName
) {} ) {}

View File

@@ -30,6 +30,10 @@ public class FeedPostEntity {
@Column(name = "bild", columnDefinition = "MEDIUMTEXT") @Column(name = "bild", columnDefinition = "MEDIUMTEXT")
private List<String> bilder; private List<String> bilder;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 10, columnDefinition = "VARCHAR(10) DEFAULT 'USER'")
private PosterType posterType = PosterType.USER;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(nullable = false, length = 10) @Column(nullable = false, length = 10)
private BeitragTyp beitragTyp; private BeitragTyp beitragTyp;
@@ -43,6 +47,9 @@ public class FeedPostEntity {
@Column(nullable = false) @Column(nullable = false)
private LocalDateTime createdAt; private LocalDateTime createdAt;
@Column
private LocalDateTime editedAt;
@Column(columnDefinition = "TEXT") @Column(columnDefinition = "TEXT")
private String targetUrl; private String targetUrl;
} }

View File

@@ -0,0 +1,5 @@
package de.oaa.xxx.feed.entity;
public enum PosterType {
USER, LOCATION
}

View File

@@ -1,6 +1,7 @@
package de.oaa.xxx.feed.repository; package de.oaa.xxx.feed.repository;
import de.oaa.xxx.feed.entity.FeedPostEntity; import de.oaa.xxx.feed.entity.FeedPostEntity;
import de.oaa.xxx.feed.entity.PosterType;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice; import org.springframework.data.domain.Slice;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
@@ -20,6 +21,10 @@ public interface FeedPostRepository extends JpaRepository<FeedPostEntity, UUID>
List<FeedPostEntity> findByAuthorIdOrderByCreatedAtDesc(UUID authorId, Pageable pageable); List<FeedPostEntity> findByAuthorIdOrderByCreatedAtDesc(UUID authorId, Pageable pageable);
List<FeedPostEntity> findByAuthorIdAndPosterTypeOrderByCreatedAtDesc(UUID authorId, PosterType posterType, Pageable pageable);
List<FeedPostEntity> findByAuthorIdInAndPosterTypeAndCreatedAtAfterOrderByCreatedAtDesc(List<UUID> authorIds, PosterType posterType, LocalDateTime since);
@Transactional @Transactional
void deleteByAuthorId(UUID authorId); void deleteByAuthorId(UUID authorId);
} }

View File

@@ -16,6 +16,9 @@ public interface FeedPostVoteRepository extends JpaRepository<FeedPostVoteEntity
long countByOptionId(UUID optionId); long countByOptionId(UUID optionId);
@Transactional
void deleteByOptionId(UUID optionId);
@Transactional @Transactional
void deleteByPostId(UUID postId); void deleteByPostId(UUID postId);
} }

View File

@@ -19,6 +19,7 @@ import org.springframework.web.bind.annotation.*;
import java.security.Principal; import java.security.Principal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.*; import java.util.*;
import java.util.stream.Collectors;
@RestController @RestController
public class GruppenbeitragController { public class GruppenbeitragController {
@@ -65,6 +66,8 @@ public class GruppenbeitragController {
} }
record CreateBeitragRequest(String beitragTyp, String text, Boolean multiChoice, List<String> optionen, List<String> bilder) {} record CreateBeitragRequest(String beitragTyp, String text, Boolean multiChoice, List<String> optionen, List<String> bilder) {}
record UpdateOptionRequest(UUID optionId, String text) {}
record UpdateBeitragRequest(String text, List<String> bilder, List<UpdateOptionRequest> optionen, Boolean multiChoice) {}
record PostsPage(List<GruppenbeitragDto> posts, boolean hasMore) {} record PostsPage(List<GruppenbeitragDto> posts, boolean hasMore) {}
record VoteRequest(UUID optionId) {} record VoteRequest(UUID optionId) {}
record ReportRequest(String grund) {} record ReportRequest(String grund) {}
@@ -153,6 +156,74 @@ public class GruppenbeitragController {
return ResponseEntity.status(201).body(toDto(beitrag, myId)); return ResponseEntity.status(201).body(toDto(beitrag, myId));
} }
// ── PUT /gruppen/{id}/posts/{postId} ──
@PutMapping("/gruppen/{id}/posts/{postId}")
public ResponseEntity<GruppenbeitragDto> updatePost(@PathVariable("id") UUID id,
@PathVariable("postId") UUID postId,
@RequestBody UpdateBeitragRequest req,
Principal principal) {
UUID myId = resolveMyId(principal);
if (myId == null) return ResponseEntity.status(401).build();
if (req.text() == null || req.text().isBlank()) return ResponseEntity.badRequest().build();
var bOpt = beitragRepository.findById(postId);
if (bOpt.isEmpty()) return ResponseEntity.notFound().build();
GruppenbeitragEntity beitrag = bOpt.get();
if (!beitrag.getAuthorId().equals(myId)) return ResponseEntity.status(403).build();
hashtagService.deleteForPost("GROUP", postId);
beitrag.setText(req.text().trim());
beitrag.setBilder(req.bilder() != null ? req.bilder() : List.of());
beitrag.setEditedAt(LocalDateTime.now());
if (beitrag.getBeitragTyp() == BeitragTyp.UMFRAGE && req.multiChoice() != null) {
beitrag.setMultiChoice(req.multiChoice());
}
beitragRepository.save(beitrag);
hashtagService.saveForPost(beitrag.getText(), "GROUP", beitrag.getBeitragId(), beitrag.getCreatedAt());
if (req.optionen() != null && beitrag.getBeitragTyp() == BeitragTyp.UMFRAGE) {
List<UmfrageOptionEntity> existingOpts = optionRepository.findByBeitragIdOrderByReihenfolge(postId);
Set<UUID> requestedIds = req.optionen().stream()
.filter(o -> o.optionId() != null)
.map(UpdateOptionRequest::optionId)
.collect(Collectors.toSet());
for (UmfrageOptionEntity existing : existingOpts) {
if (!requestedIds.contains(existing.getOptionId())) {
stimmeRepository.deleteByOptionId(existing.getOptionId());
optionRepository.delete(existing);
}
}
int reihenfolge = 0;
for (var optReq : req.optionen()) {
if (optReq.text() == null || optReq.text().isBlank()) continue;
final int r = reihenfolge++;
if (optReq.optionId() != null) {
optionRepository.findById(optReq.optionId()).ifPresent(opt -> {
if (opt.getBeitragId().equals(postId)) {
opt.setText(optReq.text().trim());
opt.setReihenfolge(r);
optionRepository.save(opt);
}
});
} else {
UmfrageOptionEntity newOpt = new UmfrageOptionEntity();
newOpt.setOptionId(UUID.randomUUID());
newOpt.setBeitragId(postId);
newOpt.setText(optReq.text().trim());
newOpt.setReihenfolge(r);
optionRepository.save(newOpt);
}
}
}
LOGGER.info("User {} hat Beitrag {} in Gruppe {} bearbeitet", myId, postId, id);
return ResponseEntity.ok(toDto(beitrag, myId));
}
// ── DELETE /gruppen/{id}/posts/{postId} ── // ── DELETE /gruppen/{id}/posts/{postId} ──
@DeleteMapping("/gruppen/{id}/posts/{postId}") @DeleteMapping("/gruppen/{id}/posts/{postId}")
@@ -354,6 +425,7 @@ public class GruppenbeitragController {
author != null ? author.getName() : "Unbekannt", author != null ? author.getName() : "Unbekannt",
author != null ? author.getProfilePicture() : null, author != null ? author.getProfilePicture() : null,
b.getBeitragTyp().name(), b.getText(), b.getMultiChoice(), b.getBilder(), b.getCreatedAt(), b.getBeitragTyp().name(), b.getText(), b.getMultiChoice(), b.getBilder(), b.getCreatedAt(),
likeCount, likedByMe, kommentarCount, optionen, myVoteOptionIds, reported); likeCount, likedByMe, kommentarCount, optionen, myVoteOptionIds, reported,
b.getEditedAt());
} }
} }

View File

@@ -20,5 +20,6 @@ public record GruppenbeitragDto(
long kommentarCount, long kommentarCount,
List<UmfrageOptionDto> optionen, List<UmfrageOptionDto> optionen,
List<UUID> myVoteOptionIds, List<UUID> myVoteOptionIds,
boolean reported boolean reported,
LocalDateTime editedAt
) {} ) {}

View File

@@ -42,4 +42,7 @@ public class GruppenbeitragEntity {
@Column(nullable = false) @Column(nullable = false)
private LocalDateTime createdAt; private LocalDateTime createdAt;
@Column
private LocalDateTime editedAt;
} }

View File

@@ -16,6 +16,9 @@ public interface UmfrageStimmeRepository extends JpaRepository<UmfrageStimmeEnti
long countByOptionId(UUID optionId); long countByOptionId(UUID optionId);
@Transactional
void deleteByOptionId(UUID optionId);
@Transactional @Transactional
void deleteByBeitragId(UUID beitragId); void deleteByBeitragId(UUID beitragId);
} }

View File

@@ -33,6 +33,7 @@ import de.oaa.xxx.location.repository.LocationEventRepository;
import de.oaa.xxx.location.repository.LocationFollowRepository; import de.oaa.xxx.location.repository.LocationFollowRepository;
import de.oaa.xxx.location.repository.LocationRepository; import de.oaa.xxx.location.repository.LocationRepository;
import de.oaa.xxx.feed.entity.FeedPostEntity; import de.oaa.xxx.feed.entity.FeedPostEntity;
import de.oaa.xxx.feed.entity.PosterType;
import de.oaa.xxx.feed.repository.FeedPostRepository; import de.oaa.xxx.feed.repository.FeedPostRepository;
import de.oaa.xxx.gruppe.BeitragTyp; import de.oaa.xxx.gruppe.BeitragTyp;
import de.oaa.xxx.social.SystemMessageService; import de.oaa.xxx.social.SystemMessageService;
@@ -168,13 +169,13 @@ public class LocationEventController {
// Feed-Post automatisch anlegen // Feed-Post automatisch anlegen
try { try {
String locationName = locOpt.get().getName();
String dateStr = event.getStartAt().format( String dateStr = event.getStartAt().format(
java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy 'um' HH:mm 'Uhr'")); java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy 'um' HH:mm 'Uhr'"));
FeedPostEntity feedPost = new FeedPostEntity(); FeedPostEntity feedPost = new FeedPostEntity();
feedPost.setPostId(UUID.randomUUID()); feedPost.setPostId(UUID.randomUUID());
feedPost.setAuthorId(myId); feedPost.setAuthorId(locationId);
feedPost.setText("📍 Neue Veranstaltung bei " + locationName + ": \"" + event.getTitle() + "\" - " + dateStr); feedPost.setPosterType(PosterType.LOCATION);
feedPost.setText("📍 Neue Veranstaltung: \"" + event.getTitle() + "\" " + dateStr);
feedPost.setBilder(event.getImageData() != null ? List.of(event.getImageData()) : List.of()); feedPost.setBilder(event.getImageData() != null ? List.of(event.getImageData()) : List.of());
feedPost.setBeitragTyp(BeitragTyp.TEXT); feedPost.setBeitragTyp(BeitragTyp.TEXT);
feedPost.setPublic(true); feedPost.setPublic(true);
@@ -218,6 +219,25 @@ public class LocationEventController {
} }
eventRepo.save(event); eventRepo.save(event);
// Feed-Post für Aktualisierung anlegen
try {
String dateStr = event.getStartAt().format(
java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy 'um' HH:mm 'Uhr'"));
FeedPostEntity feedPost = new FeedPostEntity();
feedPost.setPostId(UUID.randomUUID());
feedPost.setAuthorId(locationId);
feedPost.setPosterType(PosterType.LOCATION);
feedPost.setText("📅 Veranstaltung aktualisiert: \"" + event.getTitle() + "\" " + dateStr);
feedPost.setBilder(event.getImageData() != null ? List.of(event.getImageData()) : List.of());
feedPost.setBeitragTyp(BeitragTyp.TEXT);
feedPost.setPublic(true);
feedPost.setCreatedAt(LocalDateTime.now());
feedPost.setTargetUrl("/community/event-detail.html?id=" + event.getEventId());
feedPostRepo.save(feedPost);
} catch (Exception ex) {
LOGGER.warn("Feed-Post für Event-Update {} konnte nicht angelegt werden: {}", event.getEventId(), ex.getMessage());
}
return ResponseEntity.ok(toDetail(event, locOpt.get().getName(), myId)); return ResponseEntity.ok(toDetail(event, locOpt.get().getName(), myId));
} }

View File

@@ -7,6 +7,7 @@
<title>Profil xXx Sphere</title> <title>Profil xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css"> <link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css"> <link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="/css/community.css">
<style> <style>
/* ── Profile Header ── */ /* ── Profile Header ── */
.profil-header { .profil-header {
@@ -487,50 +488,13 @@
.vorliebe-chip.bw-EHER_NICHT { border-color:#fb8c00; color:#fb8c00; } .vorliebe-chip.bw-EHER_NICHT { border-color:#fb8c00; color:#fb8c00; }
.vorliebe-chip.bw-GEHT_GAR_NICHT { border-color:#e53935; color:#e53935; } .vorliebe-chip.bw-GEHT_GAR_NICHT { border-color:#e53935; color:#e53935; }
/* ── Post cards (profile posts tab) ── */ /* ── Post-Bild-Wrap (Profil-spezifisch) ── */
.post-card { background:var(--color-card); border:1px solid var(--color-secondary); border-radius:10px; padding:1rem; margin-bottom:0.9rem; }
.post-header { display:flex; align-items:center; gap:0.7rem; margin-bottom:0.6rem; }
.post-avatar { width:36px; height:36px; border-radius:50%; background:var(--color-secondary); display:flex; align-items:center; justify-content:center; font-size:0.95rem; flex-shrink:0; overflow:hidden; }
.post-avatar img { width:100%; height:100%; object-fit:cover; }
.post-author { font-weight:600; font-size:0.9rem; }
.post-date { font-size:0.75rem; color:var(--color-muted); margin-left:auto; }
.post-text { font-size:0.95rem; line-height:1.5; white-space:pre-wrap; word-break:break-word; }
.post-bild { width:100%; max-height:360px; object-fit:contain; border-radius:6px; margin-top:0.5rem; display:block; transition:opacity 0.2s; }
.post-bild-wrap { position:relative; cursor:pointer; display:block; } .post-bild-wrap { position:relative; cursor:pointer; display:block; }
.post-bild-wrap:hover .post-bild { opacity:0.82; } .post-bild-wrap:hover .post-bild { opacity:0.82; }
.post-bild-hover-icon { position:absolute; inset:0; display:flex; align-items:center; justify-content:center; opacity:0; transition:opacity 0.2s; pointer-events:none; font-size:1.6rem; } .post-bild-hover-icon { position:absolute; inset:0; display:flex; align-items:center; justify-content:center; opacity:0; transition:opacity 0.2s; pointer-events:none; font-size:1.6rem; }
.post-bild-wrap:hover .post-bild-hover-icon { opacity:1; } .post-bild-wrap:hover .post-bild-hover-icon { opacity:1; }
.post-actions { display:flex; gap:1rem; margin-top:0.75rem; align-items:center; flex-wrap:wrap; } /* ── Bild-Navigation in Lightbox ── */
.post-action-btn { background:none; border:none; color:var(--color-muted); cursor:pointer; font-size:0.85rem; padding:0; display:flex; align-items:center; gap:0.3rem; margin:0; width:auto; }
.post-action-btn:hover { color:var(--color-primary); background:none; }
.post-action-btn.active { color:var(--color-primary); }
.post-delete { margin-left:auto; }
.post-delete:hover { color:#c0392b !important; }
.umfrage-option-bar { margin:0.3rem 0; cursor:pointer; border-radius:6px; overflow:hidden; border:1px solid var(--color-secondary); position:relative; transition:border-color 0.15s; }
.umfrage-option-bar:hover { border-color:var(--color-primary); }
.umfrage-option-bar.voted { border-color:var(--color-primary); }
.umfrage-bar-fill { position:absolute; inset:0; background:rgba(var(--color-primary-rgb,180,0,60),0.15); transition:width 0.4s; }
.umfrage-bar-content { position:relative; display:flex; justify-content:space-between; padding:0.45rem 0.75rem; font-size:0.88rem; }
.umfrage-total { font-size:0.78rem; color:var(--color-muted); margin-top:0.3rem; }
/* ── Post / Bild Lightbox ── */
.lightbox { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.88); z-index:400; align-items:center; justify-content:center; }
.lightbox.open { display:flex; }
.lb-layout { display:flex; max-width:min(1340px, calc(100vw - 2rem)); width:95vw; height:min(90vh, 1100px); background:var(--color-card); border-radius:12px; overflow:hidden; position:relative; }
.lb-post-side .post-bild { max-height:1024px; }
.lb-close { position:absolute; top:0.6rem; right:0.6rem; background:rgba(0,0,0,0.55); border:none; color:#fff; font-size:1.1rem; width:2rem; height:2rem; border-radius:50%; cursor:pointer; z-index:10; display:flex; align-items:center; justify-content:center; padding:0; margin:0; }
.lb-post-side { flex:1; overflow-y:auto; padding:1.25rem; border-right:1px solid var(--color-secondary); min-width:0; }
.lb-comments-panel { width:300px; flex-shrink:0; display:flex; flex-direction:column; }
.lb-comments-header { font-size:0.78rem; font-weight:600; color:var(--color-muted); text-transform:uppercase; letter-spacing:0.06em; padding:0.7rem 1rem; border-bottom:1px solid var(--color-secondary); flex-shrink:0; }
.lb-comments-list { flex:1; overflow-y:auto; padding:0.75rem; }
.lb-comment-compose { padding:0.75rem; border-top:1px solid var(--color-secondary); display:flex; flex-direction:column; gap:0.5rem; flex-shrink:0; }
.lb-comment-compose textarea { width:100%; font-size:0.85rem; padding:0.35rem 0.6rem; resize:none; background:var(--color-secondary); border:1px solid var(--color-secondary); border-radius:6px; color:var(--color-text); font-family:inherit; outline:none; transition:border-color 0.2s; box-sizing:border-box; }
.lb-comment-compose textarea:focus { border-color:var(--color-primary); }
.lb-comment-compose-actions { display:flex; gap:0.5rem; justify-content:flex-end; }
.lb-comment-compose button { width:auto; margin:0; padding:0.35rem 0.75rem; font-size:0.8rem; }
.lb-img-nav { display:flex; gap:0.75rem; align-items:center; justify-content:center; margin-top:0.75rem; flex-wrap:wrap; } .lb-img-nav { display:flex; gap:0.75rem; align-items:center; justify-content:center; margin-top:0.75rem; flex-wrap:wrap; }
.compose-action-btn { background:none; border:1px solid var(--color-secondary); color:var(--color-muted); border-radius:6px; padding:0.35rem 0.6rem; font-size:0.95rem; cursor:pointer; margin:0; width:auto; }
@media (max-width:650px) { .lb-layout { flex-direction:column; height:95vh; } .lb-post-side { border-right:none; border-bottom:1px solid var(--color-secondary); max-height:55vh; } .lb-comments-panel { width:100%; } }
</style> </style>
</head> </head>
<body class="app"> <body class="app">
@@ -652,8 +616,9 @@
<script src="/js/shared.js"></script> <script src="/js/shared.js"></script>
<script src="/js/image-viewer.js"></script> <script src="/js/image-viewer.js"></script>
<script src="/js/icons.js"></script> <script src="/js/icons.js"></script>
<script src="/js/nav.js"></script> <script src="/js/nav.js"></script>
<script src="/js/social-sidebar.js"></script> <script src="/js/social-sidebar.js"></script>
<script src="/js/hashtag.js"></script>
<script src="/js/meldung.js"></script> <script src="/js/meldung.js"></script>
<script> <script>
// ── State ── // ── State ──
@@ -680,6 +645,9 @@
let activeLbPostId = null; let activeLbPostId = null;
let activeLbMode = null; // 'post' | 'image' let activeLbMode = null; // 'post' | 'image'
let activeLbImageIdx = null; let activeLbImageIdx = null;
const profilPostBilder = new Map(); // postId → bilder[] für Lightbox-Carousel
const profilPostCache = {}; // postId → post-Objekt für Edit
const profilEditBilder = new Map(); // postId → bilder[] während Bearbeitung
// ── Label maps ── // ── Label maps ──
const GESCHLECHT_LABEL = { WEIBLICH: 'weiblich', DIVERS: 'divers', MAENNLICH: 'männlich' }; const GESCHLECHT_LABEL = { WEIBLICH: 'weiblich', DIVERS: 'divers', MAENNLICH: 'männlich' };
@@ -719,6 +687,7 @@
} }
myUserId = me ? me.userId : null; myUserId = me ? me.userId : null;
initLb(myUserId);
isOwnProfile = !previewMode && me && me.userId === profile.userId; isOwnProfile = !previewMode && me && me.userId === profile.userId;
profileData = profile; profileData = profile;
allImages = images; allImages = images;
@@ -1248,6 +1217,7 @@
} }
function renderLbImageBody() { function renderLbImageBody() {
document.querySelector('#postLightbox .lb-layout')?.classList.remove('lb-text-only');
const img = allImages[activeLbImageIdx]; const img = allImages[activeLbImageIdx];
const total = allImages.length; const total = allImages.length;
const prevDisabled = activeLbImageIdx === 0 ? 'disabled' : ''; const prevDisabled = activeLbImageIdx === 0 ? 'disabled' : '';
@@ -1589,42 +1559,48 @@
const avatarHtml = p.authorPicture const avatarHtml = p.authorPicture
? `<img src="data:image/png;base64,${p.authorPicture}" alt="">` ? `<img src="data:image/png;base64,${p.authorPicture}" alt="">`
: '◉'; : '◉';
const bildRaw = bilderCarousel(p.bilder); profilPostCache[p.postId] = p;
const bildHtml = bildRaw profilPostBilder.set(p.postId, p.bilder || []);
? `<div class="post-bild-wrap" data-post-id="${p.postId}">${bildRaw}</div>` const bildHtml = bilderGrid(p.bilder);
: '';
const privacyLabel = p.isPublic ? '' : '<span style="font-size:0.72rem;color:var(--color-muted);margin-left:0.4rem;">🔒 privat</span>'; const privacyLabel = p.isPublic ? '' : '<span style="font-size:0.72rem;color:var(--color-muted);margin-left:0.4rem;">🔒 privat</span>';
const editedLabel = p.editedAt ? ' <span class="edited-label" style="font-size:0.75rem;color:var(--color-muted);">(bearbeitet)</span>' : '';
let umfrageHtml = ''; let umfrageHtml = '';
if (p.beitragTyp === 'UMFRAGE' && p.optionen && p.optionen.length > 0) { if (p.beitragTyp === 'UMFRAGE' && p.optionen && p.optionen.length > 0) {
const totalVotes = p.optionen.reduce((s, o) => s + o.stimmenCount, 0); const totalVotes = p.optionen.reduce((s, o) => s + o.stimmenCount, 0);
umfrageHtml = '<div style="margin-top:0.5rem;">' + p.optionen.map(o => { umfrageHtml = p.optionen.map(o => {
const pct = totalVotes > 0 ? Math.round(o.stimmenCount / totalVotes * 100) : 0; const pct = totalVotes > 0 ? Math.round(o.stimmenCount / totalVotes * 100) : 0;
const voted = p.myVoteOptionIds && p.myVoteOptionIds.includes(o.optionId); const voted = p.myVoteOptionIds && p.myVoteOptionIds.includes(o.optionId);
return `<div class="umfrage-option-bar${voted ? ' voted' : ''}" onclick="voteProfilPost('${p.postId}','${o.optionId}')"> return `<div class="umfrage-option-bar${voted ? ' voted' : ''}" onclick="voteProfilPost('${p.postId}','${o.optionId}')">
<div class="umfrage-bar-fill" style="width:${pct}%"></div> <div class="umfrage-bar-fill" style="width:${pct}%"></div>
<div class="umfrage-bar-content"><span>${esc(o.text)}</span><span>${pct}%</span></div> <div class="umfrage-bar-content"><span>${esc(o.text)}</span><span>${pct}%</span></div>
</div>`; </div>`;
}).join('') + `<div class="umfrage-total">${totalVotes} Stimme${totalVotes !== 1 ? 'n' : ''}</div></div>`; }).join('') + `<div class="umfrage-total">${totalVotes} Stimme${totalVotes !== 1 ? 'n' : ''}</div>`;
} }
const canDelete = p.authorId === myUserId; const isOwn = p.authorId === myUserId;
const deleteBtn = canDelete const rightBtns = isOwn
? `<button class="post-action-btn post-delete" onclick="deleteProfilPost('${p.postId}')">🗑</button>` ? `<div style="margin-left:auto;display:flex;gap:0.25rem;">
<button class="post-action-btn" onclick="event.stopPropagation();startProfilEdit('${p.postId}')" title="Bearbeiten" style="color:var(--color-muted)">✏</button>
<button class="post-action-btn post-delete" onclick="event.stopPropagation();deleteProfilPost('${p.postId}')">🗑</button>
</div>`
: ''; : '';
return `<div class="post-card" id="pp-${p.postId}"> return `<div class="post-card" id="pp-${p.postId}">
<div class="post-header"> <div class="post-header">
<div class="post-avatar">${avatarHtml}</div> <div class="post-avatar"><a href="/community/benutzer.html?userId=${p.authorId}" onclick="event.stopPropagation()" style="display:contents;">${avatarHtml}</a></div>
<div> <div>
<div class="post-author">${esc(p.authorName)}${privacyLabel}</div> <div class="post-author"><a href="/community/benutzer.html?userId=${p.authorId}" style="color:inherit;text-decoration:none;" onclick="event.stopPropagation()">${esc(p.authorName)}</a>${privacyLabel}</div>
<div class="post-date">${fmtDate(p.createdAt)}</div> <div class="post-date" id="ppm-${p.postId}">${fmtDate(p.createdAt)}${editedLabel}</div>
</div> </div>
${deleteBtn} ${rightBtns}
</div> </div>
<div id="ppva-${p.postId}">
<div class="post-text">${esc(p.text)}</div> <div class="post-text">${esc(p.text)}</div>
${bildHtml} <div id="ppbi-${p.postId}" class="post-bild-wrap" data-post-id="${p.postId}">${bildHtml}</div>
${umfrageHtml} </div>
<div id="ppea-${p.postId}" style="display:none;"></div>
<div id="ppum-${p.postId}">${umfrageHtml}</div>
<div class="post-actions"> <div class="post-actions">
<button class="post-action-btn${p.likedByMe ? ' active' : ''}" id="pp-like-${p.postId}" onclick="likeProfilPost('${p.postId}')"> <button class="post-action-btn${p.likedByMe ? ' active' : ''}" id="pp-like-${p.postId}" onclick="likeProfilPost('${p.postId}')">
♥ <span id="pp-lc-${p.postId}">${p.likeCount}</span> ♥ <span id="pp-lc-${p.postId}">${p.likeCount}</span>
@@ -1665,6 +1641,73 @@
if (res.ok) document.getElementById('pp-' + postId)?.remove(); if (res.ok) document.getElementById('pp-' + postId)?.remove();
} }
// ── Profil-Post-Bearbeitung ──
function startProfilEdit(postId) {
const post = profilPostCache[postId];
if (!post) return;
startPostEdit({
postId,
prefix: 'pp',
data: post,
editBilderMap: profilEditBilder,
saveFn: 'saveProfilEdit',
cancelFn: 'cancelProfilEdit',
addImgFn: 'profilEditAddImg',
addOptionFn: 'profilEditAddOption',
rmImgFn: 'profilEditRmImg'
});
}
function cancelProfilEdit(postId) {
cancelPostEdit(postId, 'pp', profilEditBilder);
}
function profilEditRmImg(postId, idx) {
profilEditBilder.get(postId)?.splice(idx, 1);
_renderEditThumbs(profilEditBilder, postId, 'pp', 'profilEditRmImg');
}
function profilEditAddImg(input, postId) {
const arr = profilEditBilder.get(postId);
if (!arr) return;
[...input.files].forEach(f => processImageFile(f, arr, () => {
_renderEditThumbs(profilEditBilder, postId, 'pp', 'profilEditRmImg');
}));
input.value = '';
}
function profilEditAddOption(postId) {
editAddOptionRow('ppeo-' + postId);
}
async function saveProfilEdit(postId) {
const post = profilPostCache[postId];
await savePostEdit({
postId,
prefix: 'pp',
endpoint: '/feed/posts/' + postId,
isUmfrage: post?.beitragTyp === 'UMFRAGE',
editBilderMap: profilEditBilder,
onSuccess: updated => {
profilPostCache[postId] = { ...profilPostCache[postId], text: updated.text, bilder: updated.bilder || [], optionen: updated.optionen || [] };
profilPostBilder.set(postId, updated.bilder || []);
let umfrageHtml = '';
if (post?.beitragTyp === 'UMFRAGE' && updated.optionen) {
const totalVotes = updated.optionen.reduce((s, o) => s + o.stimmenCount, 0);
umfrageHtml = updated.optionen.map(o => {
const pct = totalVotes > 0 ? Math.round(o.stimmenCount / totalVotes * 100) : 0;
return `<div class="umfrage-option-bar" onclick="voteProfilPost('${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>`;
}
applyPostEditDom(postId, 'pp', updated, umfrageHtml);
}
});
}
// ── Lightbox (Post + Bild) ── // ── Lightbox (Post + Bild) ──
function openPostLb(postId) { function openPostLb(postId) {
activeLbMode = 'post'; activeLbMode = 'post';
@@ -1674,7 +1717,9 @@
if (card) { if (card) {
const clone = card.cloneNode(true); const clone = card.cloneNode(true);
clone.querySelectorAll('.post-actions').forEach(el => el.remove()); clone.querySelectorAll('.post-actions').forEach(el => el.remove());
clone.querySelectorAll('[id^="ppea-"]').forEach(el => el.remove());
document.getElementById('lbPostBody').innerHTML = clone.innerHTML; document.getElementById('lbPostBody').innerHTML = clone.innerHTML;
_lbSetupContent(postId, 'pp', profilPostBilder.get(postId) || []);
} }
loadLbComments(); loadLbComments();
document.getElementById('postLightbox').classList.add('open'); document.getElementById('postLightbox').classList.add('open');
@@ -1728,7 +1773,6 @@
if (e.target === document.getElementById('postLightbox')) closeLb(); if (e.target === document.getElementById('postLightbox')) closeLb();
}); });
document.addEventListener('keydown', e => { document.addEventListener('keydown', e => {
if (e.key === 'Escape' && document.getElementById('postLightbox').classList.contains('open')) closeLb();
if (activeLbMode === 'image') { if (activeLbMode === 'image') {
if (e.key === 'ArrowLeft') lbGalleryNav(-1); if (e.key === 'ArrowLeft') lbGalleryNav(-1);
if (e.key === 'ArrowRight') lbGalleryNav(1); if (e.key === 'ArrowRight') lbGalleryNav(1);

View File

@@ -7,135 +7,49 @@
<title>Feed xXx Sphere</title> <title>Feed xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css"> <link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css"> <link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="/css/community.css">
<style> <style>
.tabs { display:flex; gap:0; margin-bottom:1.5rem; border-bottom:1px solid var(--color-secondary); } .post-card { cursor:pointer; }
.tab-btn { background:none; border:none; border-bottom:3px solid transparent; border-radius:0; padding:0.6rem 1.25rem; font-size:0.95rem; font-weight:600; color:var(--color-muted); cursor:pointer; margin-bottom:-1px; transition:color 0.15s,border-color 0.15s; }
.tab-btn:hover { color:var(--color-text); background:none; }
.tab-btn.active { color:var(--color-primary); border-bottom-color:var(--color-primary); }
.tab-panel { display:none; }
.tab-panel.active { display:block; }
.post-compose { background:var(--color-card); border:1px solid var(--color-secondary); border-radius:10px; padding:1rem; margin-bottom:1rem; transition:border-color 0.15s; }
.post-compose.drag-over { border-color:var(--color-primary); background:rgba(var(--color-primary-rgb,180,0,60),0.06); }
.compose-type { display:flex; gap:1.5rem; margin-bottom:0.75rem; }
.compose-type label { display:flex; align-items:center; gap:0.4rem; font-size:0.9rem; cursor:pointer; }
.post-compose textarea { width:100%; padding:0.6rem 0.85rem; border:1px solid var(--color-secondary); border-radius:6px; background:var(--color-secondary); color:var(--color-text); font-size:0.95rem; outline:none; transition:border-color 0.2s; resize:vertical; min-height:70px; box-sizing:border-box; }
.post-compose textarea:focus { border-color:var(--color-primary); }
.compose-thumbs { display:none; flex-wrap:wrap; gap:0.5rem; margin-top:0.5rem; }
.compose-thumb { position:relative; width:64px; height:64px; flex-shrink:0; }
.compose-thumb img { width:64px; height:64px; object-fit:cover; border-radius:6px; display:block; }
.compose-thumb-remove { position:absolute; top:-5px; right:-5px; background:rgba(0,0,0,0.7); border:none; color:#fff; width:18px; height:18px; border-radius:50%; font-size:0.65rem; cursor:pointer; display:flex; align-items:center; justify-content:center; padding:0; margin:0; width:auto; line-height:1; }
.umfrage-options { margin-top:0.5rem; }
.umfrage-option-row { display:flex; gap:0.5rem; margin-bottom:0.4rem; }
.umfrage-option-row input { flex:1; }
.umfrage-option-row button { width:auto; margin:0; padding:0.3rem 0.6rem; font-size:0.8rem; }
.compose-footer { display:flex; justify-content:space-between; align-items:center; margin-top:0.75rem; flex-wrap:wrap; gap:0.5rem; }
.multi-toggle { font-size:0.85rem; display:flex; align-items:center; gap:0.4rem; }
.privacy-toggle { font-size:0.85rem; display:flex; align-items:center; gap:0.4rem; }
.compose-action-btn { background:none; border:1px solid var(--color-secondary); color:var(--color-muted); border-radius:6px; padding:0.35rem 0.6rem; font-size:0.95rem; cursor:pointer; margin:0; width:auto; transition:border-color 0.15s,color 0.15s; }
.compose-action-btn:hover { border-color:var(--color-primary); color:var(--color-primary); background:none; }
label.compose-action-btn { display:inline-flex; align-items:center; }
.post-card { background:var(--color-card); border:1px solid var(--color-secondary); border-radius:10px; padding:1rem; margin-bottom:0.9rem; }
.post-header { display:flex; align-items:center; gap:0.7rem; margin-bottom:0.6rem; }
.post-avatar { width:36px; height:36px; border-radius:50%; background:var(--color-secondary); display:flex; align-items:center; justify-content:center; font-size:0.95rem; flex-shrink:0; overflow:hidden; }
.post-avatar img { width:100%; height:100%; object-fit:cover; }
.post-author { font-weight:600; font-size:0.9rem; }
.post-meta { font-size:0.75rem; color:var(--color-muted); }
.post-date { font-size:0.75rem; color:var(--color-muted); margin-left:auto; }
.post-text { font-size:0.95rem; line-height:1.5; white-space:pre-wrap; word-break:break-word; }
.post-bild { width:100%; max-height:400px; object-fit:contain; border-radius:6px; margin-top:0.5rem; display:block; }
.post-actions { display:flex; gap:1rem; margin-top:0.75rem; align-items:center; flex-wrap:wrap; }
.post-action-btn { background:none; border:none; color:var(--color-muted); cursor:pointer; font-size:0.85rem; padding:0; display:flex; align-items:center; gap:0.3rem; margin:0; width:auto; }
.post-action-btn:hover { color:var(--color-primary); background:none; }
.post-action-btn.active { color:var(--color-primary); }
.post-delete { margin-left:auto; }
.post-delete:hover { color:#c0392b !important; }
/* Carousel Stile kommen aus shared.js */
.umfrage-option-bar { margin:0.3rem 0; cursor:pointer; border-radius:6px; overflow:hidden; border:1px solid var(--color-secondary); position:relative; transition:border-color 0.15s; }
.umfrage-option-bar:hover { border-color:var(--color-primary); }
.umfrage-option-bar.voted { border-color:var(--color-primary); }
.umfrage-bar-fill { position:absolute; inset:0; background:rgba(var(--color-primary-rgb,180,0,60),0.15); transition:width 0.4s; }
.umfrage-bar-content { position:relative; display:flex; justify-content:space-between; padding:0.45rem 0.75rem; font-size:0.88rem; }
.umfrage-total { font-size:0.78rem; color:var(--color-muted); margin-top:0.3rem; }
.gruppe-badge { display:inline-flex; align-items:center; gap:0.3rem; font-size:0.75rem; color:var(--color-muted); background:var(--color-secondary); border-radius:4px; padding:0.15rem 0.45rem; margin-top:0.1rem; }
.gruppe-badge a { color:inherit; text-decoration:none; }
.gruppe-badge a:hover { color:var(--color-primary); }
.empty-hint { color:var(--color-muted); font-size:0.9rem; margin-top:0.5rem; }
.sentinel { height:1px; }
.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 { 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-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 { margin-left:auto; font-size:0.82rem; color:var(--color-muted); text-decoration:none; }
.hashtag-banner-back:hover { color:var(--color-primary); } .hashtag-banner-back:hover { color:var(--color-primary); }
/* Lightbox */
.lightbox { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.88); z-index:300; align-items:center; justify-content:center; }
.lightbox.open { display:flex; }
.lb-layout { display:flex; max-width:min(1340px, calc(100vw - 2rem)); width:95vw; height:min(90vh, 1100px); background:var(--color-card); border-radius:12px; overflow:hidden; position:relative; }
.lb-post-side .post-bild { max-height:1024px; }
.lb-close { position:absolute; top:0.6rem; right:0.6rem; background:rgba(0,0,0,0.55); border:none; color:#fff; font-size:1.1rem; width:2rem; height:2rem; border-radius:50%; cursor:pointer; z-index:10; display:flex; align-items:center; justify-content:center; padding:0; margin:0; }
.lb-post-side { flex:1; overflow-y:auto; padding:1.25rem; border-right:1px solid var(--color-secondary); min-width:0; }
.lb-comments-panel { width:300px; flex-shrink:0; display:flex; flex-direction:column; }
.lb-comments-header { font-size:0.78rem; font-weight:600; color:var(--color-muted); text-transform:uppercase; letter-spacing:0.06em; padding:0.7rem 1rem; border-bottom:1px solid var(--color-secondary); flex-shrink:0; }
.lb-comments-list { flex:1; overflow-y:auto; padding:0.75rem; }
.lb-comment-compose { padding:0.75rem; border-top:1px solid var(--color-secondary); display:flex; flex-direction:column; gap:0.5rem; flex-shrink:0; }
.lb-comment-compose textarea { width:100%; font-size:0.85rem; padding:0.35rem 0.6rem; resize:none; background:var(--color-secondary); border:1px solid var(--color-secondary); border-radius:6px; color:var(--color-text); font-family:inherit; outline:none; transition:border-color 0.2s; box-sizing:border-box; }
.lb-comment-compose textarea:focus { border-color:var(--color-primary); }
.lb-comment-compose-actions { display:flex; gap:0.5rem; justify-content:flex-end; }
.lb-comment-compose button { width:auto; margin:0; padding:0.35rem 0.75rem; font-size:0.8rem; }
@media (max-width:650px) { .lb-layout { flex-direction:column; height:95vh; } .lb-post-side { border-right:none; border-bottom:1px solid var(--color-secondary); max-height:55vh; } .lb-comments-panel { width:100%; } }
/* Comment + Like-Stile kommen aus shared.js */
</style> </style>
</head> </head>
<body class="app"> <body class="app">
<div class="main"> <div class="main">
<div class="content"> <div class="content">
<!-- Hashtag-Banner (nur sichtbar wenn ?tag=… gesetzt) -->
<div class="hashtag-banner" id="hashtagBanner" style="display:none;"> <div class="hashtag-banner" id="hashtagBanner" style="display:none;">
<span>Posts mit </span><span class="hashtag-banner-tag" id="hashtagBannerLabel"></span> <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> <a class="hashtag-banner-back" href="/community/feed.html">× Zurück zum Feed</a>
</div> </div>
<div class="tabs" id="feedTabs"> <div class="tabs" id="feedTabs">
<button class="tab-btn active" id="tabMine" data-tab="mine" onclick="switchTab('mine', this)">Mein Feed</button> <button class="tab-btn active" data-tab="mine" onclick="switchTab('mine',this)">Mein Feed</button>
<button class="tab-btn" id="tabPublic" data-tab="public" onclick="switchTab('public', this)">Öffentlicher Feed</button> <button class="tab-btn" data-tab="public" onclick="switchTab('public',this)">Öffentlicher Feed</button>
</div> </div>
<!-- Mein Feed -->
<div class="tab-panel active" id="tab-mine"> <div class="tab-panel active" id="tab-mine">
<div class="post-compose" id="compose"> <div class="post-compose" id="compose">
<div class="compose-type">
<label><input type="radio" name="beitragTyp" value="TEXT" checked onchange="toggleUmfrage()"> Text</label>
<label><input type="radio" name="beitragTyp" value="UMFRAGE" onchange="toggleUmfrage()"> Umfrage</label>
</div>
<textarea id="composeText" placeholder="Was möchtest du teilen?" rows="3"></textarea> <textarea id="composeText" placeholder="Was möchtest du teilen?" rows="3"></textarea>
<div class="compose-thumbs" id="composeThumbs"></div> <div class="compose-thumbs" id="composeThumbs"></div>
<div class="umfrage-options" id="umfrageOptions" style="display:none;"> <div class="umfrage-options" id="umfrageOptions" style="display:none;">
<div id="optionList"></div> <div id="optionList"></div>
<button onclick="addOption()" style="width:auto; margin:0; padding:0.3rem 0.75rem; font-size:0.8rem; margin-top:0.4rem;">+ Option</button> <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="multi-toggle"><input type="checkbox" id="multiChoice"> Mehrfachauswahl möglich</label>
</div>
</div> </div>
<div class="compose-footer"> <div class="compose-footer">
<div style="display:flex;gap:1rem;align-items:center;flex-wrap:wrap;"> <label class="privacy-toggle"><input type="checkbox" id="isPublic"> Öffentlich</label>
<label class="multi-toggle" id="multiChoiceRow" style="display:none;">
<input type="checkbox" id="multiChoice"> Multi-Choice
</label>
<label class="privacy-toggle">
<input type="checkbox" id="isPublic"> Öffentlich
</label>
</div>
<div style="display:flex;gap:0.5rem;align-items:center;"> <div style="display:flex;gap:0.5rem;align-items:center;">
<button type="button" class="compose-action-btn" onclick="toggleEmojiPicker(this,'composeText')" title="Emoji einfügen">😊</button> <button type="button" class="compose-action-btn" onclick="toggleEmojiPicker(this,'composeText')" title="Emoji">😊</button>
<label class="compose-action-btn" title="Fotos hinzufügen">📷 <label class="compose-action-btn" title="Fotos">📷
<input type="file" id="composeBildFile" accept="image/*" multiple style="display:none;" onchange="selectComposeBilder(this)"> <input type="file" id="composeBildFile" accept="image/*" multiple style="display:none;" onchange="selectComposeBilder(this)">
</label> </label>
<button onclick="submitPost()" style="width:auto; margin:0;">Veröffentlichen</button> <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>
</div> </div>
@@ -144,24 +58,21 @@
<div class="sentinel" id="mineSentinel"></div> <div class="sentinel" id="mineSentinel"></div>
</div> </div>
<!-- Öffentlicher Feed -->
<div class="tab-panel" id="tab-public"> <div class="tab-panel" id="tab-public">
<div id="publicFeed"></div> <div id="publicFeed"></div>
<p class="empty-hint" id="publicEmpty" style="display:none;">Noch keine öffentlichen Beiträge.</p> <p class="empty-hint" id="publicEmpty" style="display:none;">Noch keine öffentlichen Beiträge.</p>
<div class="sentinel" id="publicSentinel"></div> <div class="sentinel" id="publicSentinel"></div>
</div> </div>
<!-- Hashtag-Feed (wird angezeigt wenn ?tag=… gesetzt) -->
<div id="tab-hashtag" style="display:none;"> <div id="tab-hashtag" style="display:none;">
<div id="hashtagFeed"></div> <div id="hashtagFeed"></div>
<p class="empty-hint" id="hashtagEmpty" style="display:none;">Keine Posts mit diesem Hashtag.</p> <p class="empty-hint" id="hashtagEmpty" style="display:none;">Keine Posts mit diesem Hashtag.</p>
<div class="sentinel" id="hashtagSentinel"></div> <div class="sentinel" id="hashtagSentinel"></div>
</div> </div>
</div> </div>
</div> </div>
<!-- Post Lightbox --> <!-- Lightbox (IDs geteilt mit shared.js) -->
<div class="lightbox" id="postLightbox"> <div class="lightbox" id="postLightbox">
<div class="lb-layout"> <div class="lb-layout">
<button class="lb-close" onclick="closeLb()"></button> <button class="lb-close" onclick="closeLb()"></button>
@@ -190,9 +101,7 @@
<script> <script>
// ── State ── // ── State ──
let myUserId = null; let myUserId = null;
let activeLbPostId = null; let activeHashtag = null;
let activeLbPostType = null;
let activeHashtag = null; // set when ?tag=... is in URL
const feedState = { const feedState = {
mine: { page:0, hasMore:true, loading:false, loaded:false }, mine: { page:0, hasMore:true, loading:false, loaded:false },
@@ -200,9 +109,11 @@
hashtag: { 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 = []; let composeBilderArr = [];
// ── Hashtag-Modus prüfen ── // ── Hashtag-Modus ──
const _urlTag = new URLSearchParams(window.location.search).get('tag'); const _urlTag = new URLSearchParams(window.location.search).get('tag');
if (_urlTag) { if (_urlTag) {
activeHashtag = _urlTag.replace(/^#/, '').toLowerCase(); activeHashtag = _urlTag.replace(/^#/, '').toLowerCase();
@@ -219,6 +130,7 @@
fetch('/login/me').then(r => r.ok ? r.json() : null).then(async user => { fetch('/login/me').then(r => r.ok ? r.json() : null).then(async user => {
if (user) { if (user) {
myUserId = user.userId; myUserId = user.userId;
initLb(myUserId);
if (activeHashtag) { if (activeHashtag) {
await loadFeed('hashtag'); await loadFeed('hashtag');
} else { } else {
@@ -234,18 +146,11 @@
} }
}).catch(() => {}); }).catch(() => {});
// ── Autocomplete für Compose ── // Hashtag-Autocomplete
document.addEventListener('DOMContentLoaded', () => { if (document.readyState !== 'loading') attachHashtagAutocomplete(document.getElementById('composeText'));
const ta = document.getElementById('composeText'); else document.addEventListener('DOMContentLoaded', () => attachHashtagAutocomplete(document.getElementById('composeText')));
if (ta) attachHashtagAutocomplete(ta);
});
// Fallback falls DOMContentLoaded bereits gefeuert
if (document.readyState !== 'loading') {
const ta = document.getElementById('composeText');
if (ta) attachHashtagAutocomplete(ta);
}
// ── Tab switching ── // ── Tabs ──
function switchTab(name, btn) { function switchTab(name, btn) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active'); btn.classList.add('active');
@@ -254,39 +159,27 @@
localStorage.setItem('tab_feed', name); localStorage.setItem('tab_feed', name);
if (!feedState[name].loaded) loadFeed(name); if (!feedState[name].loaded) loadFeed(name);
} }
const _savedFeedTab = localStorage.getItem('tab_feed'); const _savedTab = localStorage.getItem('tab_feed');
if (_savedFeedTab) { if (_savedTab) { const _b = document.querySelector(`.tab-btn[data-tab="${_savedTab}"]`); if (_b) switchTab(_savedTab, _b); }
const _btn = document.querySelector(`.tab-btn[data-tab="${_savedFeedTab}"]`);
if (_btn) switchTab(_savedFeedTab, _btn);
}
// ── Feed loading ── // ── Feed laden ──
async function loadFeed(tab) { async function loadFeed(tab) {
const state = feedState[tab]; const state = feedState[tab];
if (state.loading || !state.hasMore) return; if (state.loading || !state.hasMore) return;
state.loading = true; state.loading = true; state.loaded = true;
state.loaded = true;
try { try {
let url; const url = tab === 'hashtag'
if (tab === 'hashtag') { ? `/feed/hashtag?tag=${encodeURIComponent(activeHashtag)}&page=${state.page}&size=10`
url = `/feed/hashtag?tag=${encodeURIComponent(activeHashtag)}&page=${state.page}&size=10`; : `${tab === 'mine' ? '/feed/mine' : '/feed/public'}?page=${state.page}&size=10`;
} else {
const base = tab === 'mine' ? '/feed/mine' : '/feed/public';
url = `${base}?page=${state.page}&size=10`;
}
const res = await fetch(url); const res = await fetch(url);
if (!res.ok) return; if (!res.ok) return;
const data = await res.json(); const data = await res.json();
const feedEl = document.getElementById(tab + 'Feed'); const feedEl = document.getElementById(tab + 'Feed');
if (state.page === 0 && data.posts.length === 0) { if (state.page === 0 && data.posts.length === 0) document.getElementById(tab + 'Empty').style.display = '';
document.getElementById(tab + 'Empty').style.display = '';
}
data.posts.forEach(p => feedEl.insertAdjacentHTML('beforeend', renderPostCard(p, tab))); data.posts.forEach(p => feedEl.insertAdjacentHTML('beforeend', renderPostCard(p, tab)));
state.hasMore = data.hasMore; state.hasMore = data.hasMore;
state.page++; state.page++;
} finally { } finally { state.loading = false; }
state.loading = false;
}
} }
// ── Infinite Scroll ── // ── Infinite Scroll ──
@@ -302,188 +195,148 @@
observer.observe(document.getElementById('publicSentinel')); observer.observe(document.getElementById('publicSentinel'));
observer.observe(document.getElementById('hashtagSentinel')); observer.observe(document.getElementById('hashtagSentinel'));
// bilderCarousel und carNav kommen aus shared.js // ── Post-Card rendern ──
// ── Render post card ──
function renderPostCard(p, tab) { function renderPostCard(p, tab) {
const avatarHtml = p.authorPicture feedPostCache.set(p.postId, { text: p.text, bilder: p.bilder || [], beitragTyp: p.beitragTyp, optionen: p.optionen || [], myVoteOptionIds: p.myVoteOptionIds || [], multiChoice: p.multiChoice, _tab: tab });
? `<img src="data:image/png;base64,${p.authorPicture}" alt="">` 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 privacyLabel = !p.isPublic ? ' <span style="font-size:0.7rem;color:var(--color-muted);">🔒</span>' : '';
const groupBadge = p.postType === 'GROUP' && p.gruppeId 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>` ? `<span class="gruppe-badge" onclick="event.stopPropagation()">👥 <a href="/community/gruppe.html?id=${p.gruppeId}" onclick="event.stopPropagation()">${esc(p.gruppeName)}</a></span>`
: ''; : '';
const bildHtml = bilderCarousel(p.bilder, p.postId); 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>`;
}
let umfrageHtml = ''; // ── Umfrage-HTML (Feed-spezifisch, da Vote-Handler Seiten-State braucht) ──
if (p.beitragTyp === 'UMFRAGE' && p.optionen && p.optionen.length > 0) { function buildUmfrageHtml(postId, optionen, myVoteOptionIds, onVoteAttrFn) {
const totalVotes = p.optionen.reduce((s, o) => s + o.stimmenCount, 0); if (!optionen || !optionen.length) return '';
umfrageHtml = '<div style="margin-top:0.5rem;">' + p.optionen.map(o => { 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 pct = totalVotes > 0 ? Math.round(o.stimmenCount / totalVotes * 100) : 0;
const voted = p.myVoteOptionIds && p.myVoteOptionIds.includes(o.optionId); const voted = myVoteOptionIds && myVoteOptionIds.includes(o.optionId);
return `<div class="umfrage-option-bar${voted ? ' voted' : ''}" onclick="event.stopPropagation(); votePost('${p.postId}','${o.optionId}','${tab}','${p.postType}')"> 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-fill" style="width:${pct}%"></div>
<div class="umfrage-bar-content"><span>${esc(o.text)}</span><span>${pct}%</span></div> <div class="umfrage-bar-content"><span>${esc(o.text)}</span><span>${pct}%</span></div>
</div>`; </div>`;
}).join('') + `<div class="umfrage-total">${totalVotes} Stimme${totalVotes !== 1 ? 'n' : ''}</div></div>`; }).join('') + `<div class="umfrage-total">${totalVotes} Stimme${totalVotes!==1?'n':''}</div></div>`;
} }
const canDelete = p.postType === 'FEED' && p.authorId === myUserId; // ── Post-Bearbeitung (Feed) ──
const deleteBtn = canDelete function startFeedEdit(postId) {
? `<button class="post-action-btn post-delete" onclick="event.stopPropagation(); deletePost('${p.postId}')">🗑</button>` startPostEdit({ postId, prefix: 'p', data: feedPostCache.get(postId), editBilderMap: feedEditBilder,
: ''; saveFn: 'saveFeedEdit', cancelFn: 'cancelFeedEdit',
const meldenBtn = p.authorId !== myUserId addImgFn: 'feedEditAddImg', addOptionFn: 'feedEditAddOption', rmImgFn: 'feedEditRmImg' });
? `<button class="post-action-btn" onclick="event.stopPropagation(); openMeldungDialog('POST','${p.postId}')" title="Melden" style="color:var(--color-muted)">⚑</button>` }
: ''; function cancelFeedEdit(postId) { cancelPostEdit(postId, 'p', feedEditBilder); }
function feedEditRmImg(postId, idx) { feedEditBilder.get(postId).splice(idx, 1); _renderEditThumbs(feedEditBilder, postId, 'p', 'feedEditRmImg'); }
const gruppeIdAttr = p.gruppeId ? ` data-gruppe-id="${p.gruppeId}"` : ''; function feedEditAddImg(input, postId) {
const hasTarget = !!p.targetUrl; [...input.files].forEach(f => processImageFile(f, feedEditBilder.get(postId), () => _renderEditThumbs(feedEditBilder, postId, 'p', 'feedEditRmImg')));
const cardClick = hasTarget input.value = '';
? `window.location.href='${p.targetUrl}'` }
: `openLb('${p.postId}','${p.postType}')`; function feedEditAddOption(postId) { editAddOptionRow(`peo-${postId}`); }
const commentBtn = hasTarget ? '' : ` async function saveFeedEdit(postId) {
<button class="post-action-btn" onclick="event.stopPropagation(); openLb('${p.postId}','${p.postType}')"> const cached = feedPostCache.get(postId);
💬 <span id="kc-${p.postId}">${p.kommentarCount}</span> await savePostEdit({ postId, prefix: 'p', endpoint: `/feed/posts/${postId}`,
</button>`; isUmfrage: cached?.beitragTyp === 'UMFRAGE', editBilderMap: feedEditBilder,
return `<div class="post-card" id="pc-${p.postId}"${gruppeIdAttr} onclick="${cardClick}" style="cursor:pointer;"> onSuccess: updated => {
<div class="post-header"> feedPostCache.set(postId, { ...cached, text: updated.text, bilder: updated.bilder || [], optionen: updated.optionen || [], multiChoice: updated.multiChoice });
<div class="post-avatar">${avatarHtml}</div> applyPostEditDom(postId, 'p', updated,
<div> buildUmfrageHtml(postId, updated.optionen, cached?.myVoteOptionIds || [],
<div class="post-author"><a href="/community/benutzer.html?userId=${p.authorId}" style="color:inherit;text-decoration:none;" onclick="event.stopPropagation()">${esc(p.authorName)}</a>${privacyLabel}</div> (pid, oid) => `event.stopPropagation(); votePost('${pid}','${oid}','${cached?._tab}','FEED')`));
<div class="post-meta">${fmtDate(p.createdAt)}${groupBadge}</div> }
</div> });
${deleteBtn}
</div>
<div class="post-text">${renderTextWithHashtags(p.text)}</div>
${bildHtml}
${umfrageHtml}
<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>`;
} }
// ── Compose ── // ── Compose ──
function toggleUmfrage() { function selectComposeBilder(input) {
const isUmfrage = document.querySelector('input[name="beitragTyp"]:checked').value === 'UMFRAGE'; [...input.files].forEach(f => processImageFile(f, composeBilderArr, renderComposeThumbs));
document.getElementById('umfrageOptions').style.display = isUmfrage ? '' : 'none'; input.value = '';
document.getElementById('multiChoiceRow').style.display = isUmfrage ? '' : 'none';
if (isUmfrage && document.getElementById('optionList').children.length === 0) {
addOption(); addOption();
}
} }
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() { function addOption() {
const list = document.getElementById('optionList'); const list = document.getElementById('optionList');
const idx = list.children.length; const row = document.createElement('div'); row.className = 'umfrage-option-row';
const row = document.createElement('div'); row.innerHTML = `<input type="text" placeholder="Option ${list.children.length + 1}" maxlength="100">
row.className = 'umfrage-option-row';
row.innerHTML = `<input type="text" placeholder="Option ${idx + 1}" maxlength="100">
<button onclick="this.parentElement.remove()">✕</button>`; <button onclick="this.parentElement.remove()">✕</button>`;
list.appendChild(row); list.appendChild(row);
} }
function selectComposeBilder(input) { // Drag & Drop Compose
[...input.files].forEach(f => { if (f.type.startsWith('image/')) processImageFile(f); }); const _compose = document.getElementById('compose');
input.value = ''; _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)); });
function processImageFile(file) {
if (!file || !file.type.startsWith('image/')) return;
const reader = new FileReader();
reader.onload = e => {
const img = new Image();
img.onload = () => {
const maxSize = 1024;
const canvas = document.createElement('canvas');
const scale = Math.min(maxSize / img.width, maxSize / img.height, 1);
canvas.width = Math.round(img.width * scale);
canvas.height = Math.round(img.height * scale);
canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height);
const data = canvas.toDataURL('image/jpeg', 0.85).split(',')[1];
composeBilderArr.push(data);
renderComposeThumbs();
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
}
function renderComposeThumbs() {
const container = document.getElementById('composeThumbs');
container.innerHTML = '';
composeBilderArr.forEach((b, i) => {
const div = document.createElement('div');
div.className = 'compose-thumb';
div.innerHTML = `<img src="data:image/jpeg;base64,${b}" alt="">
<button class="compose-thumb-remove" onclick="removeThumb(${i})" title="Entfernen">✕</button>`;
container.appendChild(div);
});
container.style.display = composeBilderArr.length > 0 ? 'flex' : 'none';
}
function removeThumb(idx) {
composeBilderArr.splice(idx, 1);
renderComposeThumbs();
}
// ── Drag & Drop ──
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));
});
async function submitPost() { async function submitPost() {
const text = document.getElementById('composeText').value.trim(); const text = document.getElementById('composeText').value.trim();
const hasUmfrage = document.getElementById('umfrageOptions').style.display !== 'none';
if (!text && composeBilderArr.length === 0) return; if (!text && composeBilderArr.length === 0) return;
const beitragTyp = document.querySelector('input[name="beitragTyp"]:checked').value;
const multiChoice = document.getElementById('multiChoice').checked; const multiChoice = document.getElementById('multiChoice').checked;
const isPublic = document.getElementById('isPublic').checked; const isPublic = document.getElementById('isPublic').checked;
let optionen = []; let optionen = [];
if (beitragTyp === 'UMFRAGE') { if (hasUmfrage) {
optionen = Array.from(document.getElementById('optionList').querySelectorAll('input')) optionen = Array.from(document.getElementById('optionList').querySelectorAll('input')).map(i=>i.value.trim()).filter(v=>v);
.map(i => i.value.trim()).filter(v => v);
if (optionen.length < 2) { alert('Mindestens 2 Optionen erforderlich.'); return; } if (optionen.length < 2) { alert('Mindestens 2 Optionen erforderlich.'); return; }
} }
const res = await fetch('/feed/posts', { const res = await fetch('/feed/posts', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ beitragTyp, text, multiChoice, optionen, bilder: [...composeBilderArr], isPublic }) body: JSON.stringify({ beitragTyp: hasUmfrage?'UMFRAGE':'TEXT', text, multiChoice, optionen, bilder:[...composeBilderArr], isPublic })
}); });
if (!res.ok) return; if (!res.ok) return;
const post = await res.json(); const post = await res.json();
// Reset compose
document.getElementById('composeText').value = ''; document.getElementById('composeText').value = '';
composeBilderArr = []; composeBilderArr = []; renderComposeThumbs(); resetUmfrage();
renderComposeThumbs();
document.querySelector('input[name="beitragTyp"][value="TEXT"]').checked = true;
toggleUmfrage();
document.getElementById('multiChoice').checked = false; document.getElementById('multiChoice').checked = false;
document.getElementById('isPublic').checked = false; document.getElementById('isPublic').checked = false;
document.getElementById('optionList').innerHTML = '';
// Prepend to mine feed
document.getElementById('mineEmpty').style.display = 'none'; document.getElementById('mineEmpty').style.display = 'none';
document.getElementById('mineFeed').insertAdjacentHTML('afterbegin', renderPostCard(post, 'mine')); document.getElementById('mineFeed').insertAdjacentHTML('afterbegin', renderPostCard(post, 'mine'));
if (isPublic) { if (isPublic) {
document.getElementById('publicEmpty').style.display = 'none'; document.getElementById('publicEmpty').style.display = 'none';
document.getElementById('publicFeed').insertAdjacentHTML('afterbegin', renderPostCard(post, 'public')); document.getElementById('publicFeed').insertAdjacentHTML('afterbegin', renderPostCard(post, 'public'));
@@ -492,18 +345,17 @@
// ── Like ── // ── Like ──
async function likePost(postId, postType) { async function likePost(postId, postType) {
let likeEndpoint; let ep;
if (postType === 'GROUP') { if (postType === 'GROUP') {
const card = document.getElementById('pc-' + postId); const gruppeId = document.getElementById('pc-'+postId)?.dataset?.gruppeId;
const gruppeId = card?.dataset?.gruppeId;
if (!gruppeId) return; if (!gruppeId) return;
likeEndpoint = `/gruppen/${gruppeId}/posts/${postId}/like`; ep = `/gruppen/${gruppeId}/posts/${postId}/like`;
} else { } else {
likeEndpoint = `/feed/posts/${postId}/like`; ep = `/feed/posts/${postId}/like`;
} }
await fetch(likeEndpoint, { method: 'POST' }); await fetch(ep, { method:'POST' });
const btn = document.getElementById('lk-' + postId); const btn = document.getElementById('lk-'+postId);
const lc = document.getElementById('lkc-' + postId); const lc = document.getElementById('lkc-'+postId);
const was = btn.classList.contains('active'); const was = btn.classList.contains('active');
btn.classList.toggle('active', !was); btn.classList.toggle('active', !was);
lc.textContent = parseInt(lc.textContent) + (was ? -1 : 1); lc.textContent = parseInt(lc.textContent) + (was ? -1 : 1);
@@ -512,115 +364,55 @@
// ── Vote ── // ── Vote ──
async function votePost(postId, optionId, tab, postType) { async function votePost(postId, optionId, tab, postType) {
if (postType === 'GROUP') { if (postType === 'GROUP') {
const card = document.getElementById('pc-' + postId); const gruppeId = document.getElementById('pc-'+postId)?.dataset?.gruppeId;
const gruppeId = card?.dataset?.gruppeId;
if (!gruppeId) return; if (!gruppeId) return;
await fetch(`/gruppen/${gruppeId}/posts/${postId}/vote`, { await fetch(`/gruppen/${gruppeId}/posts/${postId}/vote`, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({optionId}) });
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ optionId })
});
} else { } else {
await fetch('/feed/posts/' + postId + '/vote', { await fetch(`/feed/posts/${postId}/vote`, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({optionId}) });
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ optionId })
});
} }
reloadPost(postId, tab);
}
async function reloadPost(postId, tab) {
const state = feedState[tab]; const state = feedState[tab];
state.page = 0; state.hasMore = true; state.loaded = false; state.page = 0; state.hasMore = true; state.loaded = false;
document.getElementById(tab + 'Feed').innerHTML = ''; document.getElementById(tab+'Feed').innerHTML = '';
document.getElementById(tab + 'Empty').style.display = 'none'; document.getElementById(tab+'Empty').style.display = 'none';
await loadFeed(tab); await loadFeed(tab);
} }
// ── Delete ── // ── Delete ──
async function deletePost(postId) { async function deletePost(postId) {
if (!confirm('Post löschen?')) return; if (!confirm('Post löschen?')) return;
const res = await fetch('/feed/posts/' + postId, { method: 'DELETE' }); const res = await fetch('/feed/posts/' + postId, { method:'DELETE' });
if (res.ok) { if (res.ok) document.getElementById('pc-'+postId)?.remove();
document.getElementById('pc-' + postId)?.remove();
}
} }
// ── Lightbox ── // ── Lightbox (openLb bleibt lokal, da Seiten-Cache nötig) ──
function openLb(postId, postType) { function openLb(postId, postType) {
activeLbPostId = postId;
activeLbPostType = postType;
const card = document.getElementById('pc-' + postId); const card = document.getElementById('pc-' + postId);
if (card) { if (card) {
const clone = card.cloneNode(true); const clone = card.cloneNode(true);
clone.querySelectorAll('.post-actions').forEach(el => el.remove()); clone.querySelectorAll('.post-actions').forEach(el => el.remove());
document.getElementById('lbPostBody').innerHTML = clone.innerHTML; document.getElementById('lbPostBody').innerHTML = clone.innerHTML;
_lbSetupContent(postId, 'p', feedPostCache.get(postId)?.bilder);
} }
loadLbComments(postId, postType); loadLbComments(postId, postType);
document.getElementById('postLightbox').classList.add('open'); document.getElementById('postLightbox').classList.add('open');
document.body.style.overflow = 'hidden';
} }
function openLbWithData(p) { function openLbWithData(p) {
activeLbPostId = p.postId;
activeLbPostType = p.postType || 'FEED';
const tempDiv = document.createElement('div'); const tempDiv = document.createElement('div');
tempDiv.innerHTML = renderPostCard(p, 'mine'); tempDiv.innerHTML = renderPostCard(p, 'mine');
const card = tempDiv.firstElementChild; const card = tempDiv.firstElementChild;
if (card) { if (card) {
card.querySelectorAll('.post-actions').forEach(el => el.remove()); card.querySelectorAll('.post-actions').forEach(el => el.remove());
document.getElementById('lbPostBody').innerHTML = card.innerHTML; document.getElementById('lbPostBody').innerHTML = card.innerHTML;
_lbSetupContent(p.postId, 'p', p.bilder);
} }
loadLbComments(p.postId, p.postType || 'FEED'); loadLbComments(p.postId, p.postType || 'FEED');
document.getElementById('postLightbox').classList.add('open'); document.getElementById('postLightbox').classList.add('open');
document.body.style.overflow = 'hidden';
} }
function closeLb() { document.getElementById('postLightbox').addEventListener('click', e => { if (e.target === document.getElementById('postLightbox')) closeLb(); });
document.getElementById('postLightbox').classList.remove('open');
activeLbPostId = null;
activeLbPostType = null;
}
document.getElementById('postLightbox').addEventListener('click', e => {
if (e.target === document.getElementById('postLightbox')) closeLb();
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && document.getElementById('postLightbox').classList.contains('open')) closeLb();
});
async function loadLbComments(postId, postType) {
const targetType = postType === 'GROUP' ? 'GROUP_POST' : 'FEED_POST';
const res = await fetch(`/social/kommentare?targetType=${targetType}&targetId=${postId}`);
const comments = await res.json();
document.getElementById('lbCommentsList').innerHTML = comments.length === 0
? '<p style="color:var(--color-muted);font-size:0.82rem;margin:0.4rem;">Noch keine Kommentare.</p>'
: comments.map(k => renderKommentarHtml(k, targetType, postId, { myUserId })).join('');
}
async function postLbComment() {
if (!activeLbPostId) return;
const input = document.getElementById('lbCommentInput');
const text = input.value.trim();
if (!text) return;
const targetType = activeLbPostType === 'GROUP' ? 'GROUP_POST' : 'FEED_POST';
await fetch('/social/kommentare', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetType, targetId: activeLbPostId, text })
});
input.value = '';
await loadLbComments(activeLbPostId, activeLbPostType);
const kcEl = document.getElementById('kc-' + activeLbPostId);
if (kcEl) kcEl.textContent = parseInt(kcEl.textContent) + 1;
}
// renderKommentarHtml und toggleKommentarLike kommen aus shared.js
async function deleteKommentar(kommentarId, targetType, targetId) {
await fetch('/social/kommentare/' + kommentarId, { method: 'DELETE' });
await loadLbComments(targetId, activeLbPostType);
}
// toggleEmojiPicker, insertEmoji kommen aus shared.js
// esc, fmtDate kommen aus shared.js
</script> </script>
</body> </body>
</html> </html>

View File

@@ -7,14 +7,8 @@
<title>Gruppe xXx Sphere</title> <title>Gruppe xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css"> <link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css"> <link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="/css/community.css">
<style> <style>
.tabs { display:flex; gap:0; margin-bottom:1.5rem; border-bottom:1px solid var(--color-secondary); }
.tab-btn { background:none; border:none; border-bottom:3px solid transparent; border-radius:0; padding:0.6rem 1.25rem; font-size:0.95rem; font-weight:600; color:var(--color-muted); cursor:pointer; margin-bottom:-1px; transition:color 0.15s,border-color 0.15s; }
.tab-btn:hover { color:var(--color-text); background:none; }
.tab-btn.active { color:var(--color-primary); border-bottom-color:var(--color-primary); }
.tab-panel { display:none; }
.tab-panel.active { display:block; }
/* Header */ /* Header */
.gruppe-header { display:flex; align-items:center; gap:1rem; margin-bottom:1.5rem; flex-wrap:wrap; } .gruppe-header { display:flex; align-items:center; gap:1rem; margin-bottom:1.5rem; flex-wrap:wrap; }
.gruppe-avatar { width:72px; height:72px; border-radius:12px; background:var(--color-secondary); display:flex; align-items:center; justify-content:center; font-size:2rem; flex-shrink:0; overflow:hidden; } .gruppe-avatar { width:72px; height:72px; border-radius:12px; background:var(--color-secondary); display:flex; align-items:center; justify-content:center; font-size:2rem; flex-shrink:0; overflow:hidden; }
@@ -24,48 +18,9 @@
.gruppe-header-actions { margin-left:auto; display:flex; gap:0.5rem; } .gruppe-header-actions { margin-left:auto; display:flex; gap:0.5rem; }
.gruppe-header-actions button, .gruppe-header-actions a.btn { margin:0; width:auto; padding:0.4rem 0.9rem; font-size:0.85rem; } .gruppe-header-actions button, .gruppe-header-actions a.btn { margin:0; width:auto; padding:0.4rem 0.9rem; font-size:0.85rem; }
/* Posts */ /* Compose-Typ (Gruppe-spezifisch) */
.post-compose { background:var(--color-card); border:1px solid var(--color-secondary); border-radius:10px; padding:1rem; margin-bottom:1rem; }
.compose-type { display:flex; gap:1.5rem; margin-bottom:0.75rem; } .compose-type { display:flex; gap:1.5rem; margin-bottom:0.75rem; }
.compose-type label { display:flex; align-items:center; gap:0.4rem; font-size:0.9rem; cursor:pointer; } .compose-type label { display:flex; align-items:center; gap:0.4rem; font-size:0.9rem; cursor:pointer; }
.post-compose textarea { width:100%; padding:0.6rem 0.85rem; border:1px solid var(--color-secondary); border-radius:6px; background:var(--color-secondary); color:var(--color-text); font-size:0.95rem; outline:none; transition:border-color 0.2s; resize:vertical; min-height:70px; box-sizing:border-box; }
.post-compose textarea:focus { border-color:var(--color-primary); }
.compose-thumbs { display:none; flex-wrap:wrap; gap:0.5rem; margin-top:0.5rem; }
.compose-thumb { position:relative; width:64px; height:64px; flex-shrink:0; }
.compose-thumb img { width:64px; height:64px; object-fit:cover; border-radius:6px; display:block; }
.compose-thumb-remove { position:absolute; top:-5px; right:-5px; background:rgba(0,0,0,0.7); border:none; color:#fff; width:18px; height:18px; border-radius:50%; font-size:0.65rem; cursor:pointer; display:flex; align-items:center; justify-content:center; padding:0; margin:0; width:auto; line-height:1; }
.compose-action-btn { background:none; border:1px solid var(--color-secondary); color:var(--color-muted); border-radius:6px; padding:0.35rem 0.6rem; font-size:0.95rem; cursor:pointer; margin:0; width:auto; transition:border-color 0.15s,color 0.15s; }
.compose-action-btn:hover { border-color:var(--color-primary); color:var(--color-primary); background:none; }
label.compose-action-btn { display:inline-flex; align-items:center; }
.umfrage-options { margin-top:0.5rem; }
.post-bild { width:100%; max-height:400px; object-fit:contain; border-radius:6px; margin-top:0.5rem; display:block; }
.umfrage-option-row { display:flex; gap:0.5rem; margin-bottom:0.4rem; }
.umfrage-option-row input { flex:1; }
.umfrage-option-row button { width:auto; margin:0; padding:0.3rem 0.6rem; font-size:0.8rem; }
.compose-footer { display:flex; justify-content:space-between; align-items:center; margin-top:0.75rem; flex-wrap:wrap; gap:0.5rem; }
.multi-toggle { font-size:0.85rem; display:flex; align-items:center; gap:0.4rem; }
.post-card { background:var(--color-card); border:1px solid var(--color-secondary); border-radius:10px; padding:1rem; margin-bottom:0.9rem; }
.post-header { display:flex; align-items:center; gap:0.7rem; margin-bottom:0.6rem; }
.post-avatar { width:36px; height:36px; border-radius:50%; background:var(--color-secondary); display:flex; align-items:center; justify-content:center; font-size:0.95rem; flex-shrink:0; overflow:hidden; }
.post-avatar img { width:100%; height:100%; object-fit:cover; }
.post-author { font-weight:600; font-size:0.9rem; }
.post-date { font-size:0.75rem; color:var(--color-muted); margin-left:auto; }
.post-text { font-size:0.95rem; line-height:1.5; white-space:pre-wrap; word-break:break-word; }
.post-actions { display:flex; gap:1rem; margin-top:0.75rem; align-items:center; flex-wrap:wrap; }
.post-action-btn { background:none; border:none; color:var(--color-muted); cursor:pointer; font-size:0.85rem; padding:0; display:flex; align-items:center; gap:0.3rem; }
.post-action-btn:hover { color:var(--color-primary); background:none; }
.post-action-btn.active { color:var(--color-primary); }
.post-action-btn.danger:hover { color:#c0392b; }
.post-delete { margin-left:auto; }
/* Umfrage */
.umfrage-option-bar { margin:0.3rem 0; cursor:pointer; border-radius:6px; overflow:hidden; border:1px solid var(--color-secondary); position:relative; transition:border-color 0.15s; }
.umfrage-option-bar:hover { border-color:var(--color-primary); }
.umfrage-option-bar.voted { border-color:var(--color-primary); }
.umfrage-bar-fill { position:absolute; inset:0; background:rgba(var(--color-primary-rgb,180,0,60),0.15); transition:width 0.4s; }
.umfrage-bar-content { position:relative; display:flex; justify-content:space-between; padding:0.45rem 0.75rem; font-size:0.88rem; }
.umfrage-total { font-size:0.78rem; color:var(--color-muted); margin-top:0.3rem; }
/* Kommentare */ /* Kommentare */
.comments-section { margin-top:0.75rem; border-top:1px solid var(--color-secondary); padding-top:0.75rem; } .comments-section { margin-top:0.75rem; border-top:1px solid var(--color-secondary); padding-top:0.75rem; }
@@ -111,26 +66,6 @@
.empty-hint { color:var(--color-muted); font-size:0.9rem; margin-top:0.5rem; } .empty-hint { color:var(--color-muted); font-size:0.9rem; margin-top:0.5rem; }
/* Post lightbox */
.lightbox { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.88); z-index:300; align-items:center; justify-content:center; }
.lightbox.open { display:flex; }
.lb-layout { display:flex; max-width:min(1340px, calc(100vw - 2rem)); width:95vw; height:min(90vh, 1100px); background:var(--color-card); border-radius:12px; overflow:hidden; position:relative; }
.lb-post-side .post-bild { max-height:1024px; }
.lb-close { position:absolute; top:0.6rem; right:0.6rem; background:rgba(0,0,0,0.55); border:none; color:#fff; font-size:1.1rem; width:2rem; height:2rem; border-radius:50%; cursor:pointer; z-index:10; display:flex; align-items:center; justify-content:center; padding:0; margin:0; }
.lb-post-side { flex:1; overflow-y:auto; padding:1.25rem; border-right:1px solid var(--color-secondary); min-width:0; }
.lb-comments-panel { width:300px; flex-shrink:0; display:flex; flex-direction:column; }
.lb-comments-list { flex:1; overflow-y:auto; padding:0.75rem; }
.lb-comment-compose { padding:0.75rem; border-top:1px solid var(--color-secondary); display:flex; flex-direction:column; gap:0.5rem; flex-shrink:0; }
.lb-comment-compose textarea { width:100%; font-size:0.85rem; padding:0.35rem 0.6rem; resize:none; background:var(--color-secondary); border:1px solid var(--color-secondary); border-radius:6px; color:var(--color-text); font-family:inherit; outline:none; transition:border-color 0.2s; box-sizing:border-box; }
.lb-comment-compose textarea:focus { border-color:var(--color-primary); }
.lb-comment-compose-actions { display:flex; gap:0.5rem; justify-content:flex-end; }
.lb-comment-compose button { width:auto; margin:0; padding:0.35rem 0.75rem; font-size:0.8rem; }
@media (max-width:650px) {
.lb-layout { flex-direction:column; height:95vh; }
.lb-post-side { border-right:none; border-bottom:1px solid var(--color-secondary); max-height:55vh; }
.lb-comments-panel { width:100%; }
}
/* Dialog */ /* Dialog */
.dialog-backdrop { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.6); z-index:200; align-items:center; justify-content:center; } .dialog-backdrop { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.6); z-index:200; align-items:center; justify-content:center; }
.dialog-backdrop.visible { display:flex; } .dialog-backdrop.visible { display:flex; }
@@ -164,25 +99,24 @@
<div class="tab-panel active" id="tab-posts"> <div class="tab-panel active" id="tab-posts">
<!-- Compose --> <!-- Compose -->
<div class="post-compose" id="compose" style="display:none;"> <div class="post-compose" id="compose" style="display:none;">
<div class="compose-type">
<label><input type="radio" name="beitragTyp" value="TEXT" checked onchange="toggleUmfrage()"> Text</label>
<label><input type="radio" name="beitragTyp" value="UMFRAGE" onchange="toggleUmfrage()"> Umfrage</label>
</div>
<textarea id="composeText" placeholder="Was möchtest du teilen?" rows="3"></textarea> <textarea id="composeText" placeholder="Was möchtest du teilen?" rows="3"></textarea>
<div class="compose-thumbs" id="composeThumbs"></div> <div class="compose-thumbs" id="composeThumbs"></div>
<div class="umfrage-options" id="umfrageOptions" style="display:none;"> <div class="umfrage-options" id="umfrageOptions" style="display:none;">
<div id="optionList"></div> <div id="optionList"></div>
<button onclick="addOption()" style="width:auto; margin:0; padding:0.3rem 0.75rem; font-size:0.8rem; margin-top:0.4rem;">+ Option</button> <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="multi-toggle">
<input type="checkbox" id="multiChoice"> Mehrfachauswahl möglich
</label>
</div>
</div> </div>
<div class="compose-footer"> <div class="compose-footer">
<label class="multi-toggle" id="multiChoiceRow" style="display:none;">
<input type="checkbox" id="multiChoice"> Multi-Choice
</label>
<div style="display:flex;gap:0.5rem;align-items:center;"> <div style="display:flex;gap:0.5rem;align-items:center;">
<button type="button" class="compose-action-btn" onclick="toggleEmojiPicker(this,'composeText')" title="Emoji einfügen">😊</button> <button type="button" class="compose-action-btn" onclick="toggleEmojiPicker(this,'composeText')" title="Emoji einfügen">😊</button>
<label class="compose-action-btn" title="Fotos hinzufügen">📷 <label class="compose-action-btn" title="Fotos hinzufügen">📷
<input type="file" id="composeBildFile" accept="image/*" multiple style="display:none;" onchange="selectComposeBilder(this)"> <input type="file" id="composeBildFile" accept="image/*" multiple style="display:none;" onchange="selectComposeBilder(this)">
</label> </label>
<button type="button" id="umfrageBtn" class="compose-action-btn" onclick="toggleUmfrage(this)" title="Umfrage hinzufügen">📊</button>
<button onclick="submitPost()" style="width:auto; margin:0;">Veröffentlichen</button> <button onclick="submitPost()" style="width:auto; margin:0;">Veröffentlichen</button>
</div> </div>
</div> </div>
@@ -277,18 +211,19 @@
</div> </div>
<!-- Post lightbox dialog --> <!-- Post lightbox dialog -->
<div class="lightbox" id="postDialog"> <div class="lightbox" id="postLightbox">
<div class="lb-layout"> <div class="lb-layout">
<button class="lb-close" onclick="closePostDialog()"></button> <button class="lb-close" onclick="closeLb()"></button>
<div class="lb-post-side" id="lbPostContent"></div> <div class="lb-post-side" id="lbPostBody"></div>
<div class="lb-comments-panel"> <div class="lb-comments-panel">
<div class="lb-comments-header">Kommentare</div>
<div class="lb-comments-list" id="lbCommentsList"></div> <div class="lb-comments-list" id="lbCommentsList"></div>
<div class="lb-comment-compose"> <div class="lb-comment-compose">
<textarea id="lbCommentInput" placeholder="Kommentieren…" maxlength="500" rows="3" <textarea id="lbCommentInput" placeholder="Kommentieren…" maxlength="500" rows="3"
onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();submitLbComment()}"></textarea> onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();postLbComment()}"></textarea>
<div class="lb-comment-compose-actions"> <div class="lb-comment-compose-actions">
<button type="button" class="compose-action-btn" onclick="toggleEmojiPicker(this,'lbCommentInput')" title="Emoji">😊</button> <button type="button" class="compose-action-btn" onclick="toggleEmojiPicker(this,'lbCommentInput')" title="Emoji">😊</button>
<button onclick="submitLbComment()">Senden</button> <button onclick="postLbComment()">Senden</button>
</div> </div>
</div> </div>
</div> </div>
@@ -374,6 +309,7 @@
if (!meRes.ok) { location.href='/login.html'; return; } if (!meRes.ok) { location.href='/login.html'; return; }
const me = await meRes.json(); const me = await meRes.json();
myId = me.userId; myId = me.userId;
initLb(myId);
await loadGruppe(); await loadGruppe();
await loadPosts(); await loadPosts();
@@ -471,15 +407,24 @@
} }
const gruppeEditBilder = new Map();
function renderPostCard(p) { function renderPostCard(p) {
const canEdit = p.authorId === myId;
const canDelete = (myRole === 'ADMIN' || p.authorId === myId); const canDelete = (myRole === 'ADMIN' || p.authorId === myId);
const av = p.authorPicture ? `<img src="data:image/png;base64,${p.authorPicture}" alt="">` : '◉'; const av = p.authorPicture ? `<img src="data:image/png;base64,${p.authorPicture}" alt="">` : '◉';
const bildHtml = bilderCarousel(p.bilder); const bildHtml = bilderGrid(p.bilder);
const editedLabel = p.editedAt ? ` <span style="font-size:0.75rem;color:var(--color-muted);">(bearbeitet)</span>` : '';
let body = ''; // editable view area: question/text + images
const textStyle = p.beitragTyp === 'UMFRAGE' ? ' style="font-weight:600;margin-bottom:0.5rem;"' : '';
const editableHtml = `<div class="post-text"${textStyle}>${renderTextWithHashtags(p.text)}</div><div id="gpbi-${p.beitragId}">${bildHtml}</div>`;
// poll bars (only for UMFRAGE, not editable)
let barsHtml = '';
if (p.beitragTyp === 'UMFRAGE') { if (p.beitragTyp === 'UMFRAGE') {
const total = p.optionen.reduce((s, o) => s + o.stimmenCount, 0); const total = p.optionen.reduce((s, o) => s + o.stimmenCount, 0);
const bars = p.optionen.map(o => { barsHtml = p.optionen.map(o => {
const pct = total > 0 ? Math.round(o.stimmenCount / total * 100) : 0; const pct = total > 0 ? Math.round(o.stimmenCount / total * 100) : 0;
const voted = p.myVoteOptionIds.includes(o.optionId); const voted = p.myVoteOptionIds.includes(o.optionId);
return `<div class="umfrage-option-bar ${voted?'voted':''}" onclick="event.stopPropagation(); vote('${p.beitragId}','${o.optionId}',this)"> return `<div class="umfrage-option-bar ${voted?'voted':''}" onclick="event.stopPropagation(); vote('${p.beitragId}','${o.optionId}',this)">
@@ -489,23 +434,25 @@
<span>${pct}% (${o.stimmenCount})</span> <span>${pct}% (${o.stimmenCount})</span>
</div> </div>
</div>`; </div>`;
}).join(''); }).join('') + `<div class="umfrage-total">${total} Stimme${total !== 1 ? 'n' : ''} gesamt${p.multiChoice?' · Multi-Choice':''}</div>`;
body = bars + `<div class="umfrage-total">${total} Stimme${total !== 1 ? 'n' : ''} gesamt${p.multiChoice?' · Multi-Choice':''}</div>`;
} else {
body = `<div class="post-text">${renderTextWithHashtags(p.text)}</div>${bildHtml}`;
} }
const rightBtns = (canEdit ? `<button class="post-action-btn" onclick="event.stopPropagation();startGruppeEdit('${p.beitragId}')" title="Bearbeiten" style="color:var(--color-muted)">✏</button>` : '')
+ (canDelete ? `<button class="post-action-btn danger post-delete" onclick="event.stopPropagation(); deletePost('${p.beitragId}',this)">✕</button>` : '');
return ` return `
<div class="post-card" id="post-${p.beitragId}" onclick="openPostDialog('${p.beitragId}')" style="cursor:pointer;"> <div class="post-card" id="post-${p.beitragId}" onclick="openPostDialog('${p.beitragId}')" style="cursor:pointer;">
<div class="post-header"> <div class="post-header">
<div class="post-avatar">${av}</div> <div class="post-avatar"><a href="/community/benutzer.html?userId=${p.authorId}" onclick="event.stopPropagation()" style="display:contents;">${av}</a></div>
<div> <div>
<div class="post-author"><a href="/community/benutzer.html?userId=${p.authorId}" style="color:inherit;text-decoration:none;" onclick="event.stopPropagation()">${esc(p.authorName)}</a></div> <div class="post-author"><a href="/community/benutzer.html?userId=${p.authorId}" style="color:inherit;text-decoration:none;" onclick="event.stopPropagation()">${esc(p.authorName)}</a></div>
<div class="post-meta" id="gpm-${p.beitragId}">${fmtDate(p.createdAt)}${editedLabel}</div>
</div> </div>
<div class="post-date">${fmtDate(p.createdAt)}</div> ${rightBtns ? `<div style="margin-left:auto;display:flex;gap:0.25rem;">${rightBtns}</div>` : ''}
</div> </div>
${p.beitragTyp === 'UMFRAGE' ? `<div style="font-weight:600;margin-bottom:0.5rem;">${renderTextWithHashtags(p.text)}</div>${bildHtml}` : ''} <div id="gpva-${p.beitragId}">${editableHtml}</div>
${body} <div id="gpea-${p.beitragId}" style="display:none;"></div>
<div id="gpum-${p.beitragId}">${barsHtml}</div>
<div class="post-actions"> <div class="post-actions">
<button class="post-action-btn ${p.likedByMe?'active':''}" onclick="event.stopPropagation(); toggleLike('${p.beitragId}',this)" id="like-btn-${p.beitragId}"> <button class="post-action-btn ${p.likedByMe?'active':''}" onclick="event.stopPropagation(); toggleLike('${p.beitragId}',this)" id="like-btn-${p.beitragId}">
♥ <span id="like-count-${p.beitragId}">${p.likeCount}</span> ♥ <span id="like-count-${p.beitragId}">${p.likeCount}</span>
@@ -514,11 +461,162 @@
💬 <span id="kmt-count-${p.beitragId}">${p.kommentarCount}</span> 💬 <span id="kmt-count-${p.beitragId}">${p.kommentarCount}</span>
</button> </button>
${!p.reported ? `<button class="post-action-btn" onclick="event.stopPropagation(); reportPost('${p.beitragId}',this)">⚑ Melden</button>` : '<span style="font-size:0.78rem;color:var(--color-muted);">Gemeldet</span>'} ${!p.reported ? `<button class="post-action-btn" onclick="event.stopPropagation(); reportPost('${p.beitragId}',this)">⚑ Melden</button>` : '<span style="font-size:0.78rem;color:var(--color-muted);">Gemeldet</span>'}
${canDelete ? `<button class="post-action-btn danger post-delete" onclick="event.stopPropagation(); deletePost('${p.beitragId}',this)">✕</button>` : ''}
</div> </div>
</div>`; </div>`;
} }
function buildGruppeUmfrageHtml(beitragId, optionen, myVoteOptionIds, multiChoice) {
if (!optionen || optionen.length === 0) return '';
const total = optionen.reduce((s, o) => s + o.stimmenCount, 0);
return optionen.map(o => {
const pct = total > 0 ? Math.round(o.stimmenCount / total * 100) : 0;
const voted = myVoteOptionIds.includes(o.optionId);
return `<div class="umfrage-option-bar ${voted?'voted':''}" onclick="event.stopPropagation(); vote('${beitragId}','${o.optionId}',this)">
<div class="umfrage-bar-fill" style="width:${pct}%"></div>
<div class="umfrage-bar-content"><span>${esc(o.text)}</span><span>${pct}% (${o.stimmenCount})</span></div>
</div>`;
}).join('') + `<div class="umfrage-total">${total} Stimme${total !== 1 ? 'n' : ''} gesamt${multiChoice?' · Multi-Choice':''}</div>`;
}
function startGruppeEdit(beitragId) {
const post = allPosts.find(p => p.beitragId === beitragId);
if (!post) return;
gruppeEditBilder.set(beitragId, [...(post.bilder || [])]);
document.getElementById('gpva-' + beitragId).style.display = 'none';
document.getElementById('gpum-' + beitragId).style.display = 'none';
const isUmfrage = post.beitragTyp === 'UMFRAGE';
const optionenHtml = isUmfrage
? `<div id="gpeo-${beitragId}" style="margin-top:0.5rem;">${(post.optionen || []).map((o) =>
`<div class="umfrage-option-row">
<input type="text" value="${esc(o.text)}" maxlength="200" data-option-id="${o.optionId}"
style="flex:1;padding:0.4rem 0.6rem;border:1px solid var(--color-secondary);border-radius:6px;background:var(--color-secondary);color:var(--color-text);font-size:0.9rem;"
onmousedown="event.stopPropagation()" onclick="event.stopPropagation()" onkeydown="event.stopPropagation()">
<button onmousedown="event.stopPropagation()" onclick="event.stopPropagation();this.closest('.umfrage-option-row').remove()" style="width:auto;margin:0;padding:0.3rem 0.6rem;font-size:0.8rem;">✕</button>
</div>`).join('')}
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:0.3rem;" onclick="event.stopPropagation()">
<button onmousedown="event.stopPropagation()" onclick="event.stopPropagation();gruppeEditAddOption('${beitragId}')" style="width:auto;margin:0;padding:0.3rem 0.75rem;font-size:0.8rem;">+ Option</button>
<label class="multi-toggle" onmousedown="event.stopPropagation()" onclick="event.stopPropagation()">
<input type="checkbox" id="gpmc-${beitragId}" ${post.multiChoice ? 'checked' : ''}> Mehrfachauswahl möglich
</label>
</div>
</div>`
: '';
const actionRow = `<div style="display:flex;gap:0.5rem;align-items:center;margin-top:0.5rem;" onclick="event.stopPropagation()">
<label class="compose-action-btn" title="Fotos hinzufügen">📷
<input type="file" accept="image/*" multiple style="display:none;" onchange="event.stopPropagation();gruppeEditAddImg(this,'${beitragId}')">
</label>
<button onclick="event.stopPropagation();saveGruppeEdit('${beitragId}')" style="width:auto;margin:0;">Speichern</button>
<button onclick="event.stopPropagation();cancelGruppeEdit('${beitragId}')" style="width:auto;margin:0;background:var(--color-secondary);color:var(--color-text);">Abbrechen</button>
</div>`;
const ea = document.getElementById('gpea-' + beitragId);
ea.style.display = '';
ea.onclick = e => e.stopPropagation();
ea.innerHTML = `
<textarea id="gpet-${beitragId}" style="width:100%;box-sizing:border-box;padding:0.6rem;border:1px solid var(--color-secondary);border-radius:6px;background:var(--color-secondary);color:var(--color-text);font-size:0.95rem;resize:vertical;min-height:70px;" onclick="event.stopPropagation()" onkeydown="event.stopPropagation()">${esc(post.text)}</textarea>
<div class="compose-thumbs" id="gpet-tb-${beitragId}" style="margin-top:0.4rem;"></div>
${optionenHtml}
${actionRow}`;
renderGruppeEditThumbs(beitragId);
}
function cancelGruppeEdit(beitragId) {
document.getElementById('gpva-' + beitragId).style.display = '';
document.getElementById('gpum-' + beitragId).style.display = '';
document.getElementById('gpea-' + beitragId).style.display = 'none';
gruppeEditBilder.delete(beitragId);
}
function renderGruppeEditThumbs(beitragId) {
const bilder = gruppeEditBilder.get(beitragId) || [];
const c = document.getElementById('gpet-tb-' + beitragId);
c.innerHTML = bilder.map((b, i) =>
`<div class="compose-thumb"><img src="data:image/jpeg;base64,${b}" alt="">
<button class="compose-thumb-remove" onclick="event.stopPropagation();gruppeEditRmImg('${beitragId}',${i})">✕</button></div>`
).join('');
c.style.display = bilder.length > 0 ? 'flex' : 'none';
}
function gruppeEditRmImg(beitragId, idx) {
gruppeEditBilder.get(beitragId).splice(idx, 1);
renderGruppeEditThumbs(beitragId);
}
function gruppeEditAddImg(input, beitragId) {
[...input.files].forEach(f => {
if (!f.type.startsWith('image/')) return;
const reader = new FileReader();
reader.onload = e => {
const img = new Image();
img.onload = () => {
const MAX = 1024, canvas = document.createElement('canvas');
const s = Math.min(MAX / img.width, MAX / img.height, 1);
canvas.width = Math.round(img.width * s); canvas.height = Math.round(img.height * s);
canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height);
gruppeEditBilder.get(beitragId).push(canvas.toDataURL('image/jpeg', 0.85).split(',')[1]);
renderGruppeEditThumbs(beitragId);
};
img.src = e.target.result;
};
reader.readAsDataURL(f);
});
input.value = '';
}
function gruppeEditAddOption(beitragId) {
const container = document.getElementById('gpeo-' + beitragId);
const count = container.querySelectorAll('input[type=text]').length;
const row = document.createElement('div');
row.className = 'umfrage-option-row';
row.innerHTML = `<input type="text" placeholder="Option ${count + 1}" maxlength="200"
style="flex:1;padding:0.4rem 0.6rem;border:1px solid var(--color-secondary);border-radius:6px;background:var(--color-secondary);color:var(--color-text);font-size:0.9rem;"
onmousedown="event.stopPropagation()" onclick="event.stopPropagation()" onkeydown="event.stopPropagation()">
<button onmousedown="event.stopPropagation()" onclick="event.stopPropagation();this.closest('.umfrage-option-row').remove()" style="width:auto;margin:0;padding:0.3rem 0.6rem;font-size:0.8rem;">✕</button>`;
container.insertBefore(row, container.querySelector('div:last-child'));
}
async function saveGruppeEdit(beitragId) {
const text = document.getElementById('gpet-' + beitragId).value.trim();
if (!text) return;
const post = allPosts.find(p => p.beitragId === beitragId);
const bilder = gruppeEditBilder.get(beitragId) || [];
const isUmfrageEdit = post?.beitragTyp === 'UMFRAGE';
const optionen = isUmfrageEdit
? Array.from(document.querySelectorAll(`#gpeo-${beitragId} input[type=text]`))
.map(inp => ({ optionId: inp.dataset.optionId || null, text: inp.value.trim() }))
.filter(o => o.text)
: null;
const multiChoice = isUmfrageEdit ? (document.getElementById('gpmc-' + beitragId)?.checked ?? false) : null;
const res = await fetch('/gruppen/' + gruppeId + '/posts/' + beitragId, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, bilder, optionen, multiChoice })
});
if (!res.ok) return;
const updated = await res.json();
const idx = allPosts.findIndex(p => p.beitragId === beitragId);
if (idx >= 0) allPosts[idx] = { ...allPosts[idx], text: updated.text, bilder: updated.bilder || [], optionen: updated.optionen || [], multiChoice: updated.multiChoice };
gruppeEditBilder.delete(beitragId);
const gpva = document.getElementById('gpva-' + beitragId);
gpva.querySelector('.post-text').innerHTML = renderTextWithHashtags(updated.text);
const pbi = document.getElementById('gpbi-' + beitragId);
if (pbi) pbi.innerHTML = bilderGrid(updated.bilder);
gpva.style.display = '';
document.getElementById('gpea-' + beitragId).style.display = 'none';
const gpum = document.getElementById('gpum-' + beitragId);
gpum.innerHTML = buildGruppeUmfrageHtml(beitragId, updated.optionen, post?.myVoteOptionIds || [], post?.multiChoice);
gpum.style.display = '';
const meta = document.getElementById('gpm-' + beitragId);
if (meta && !meta.querySelector('.edited-label')) {
meta.insertAdjacentHTML('beforeend', ' <span class="edited-label" style="font-size:0.75rem;color:var(--color-muted);">(bearbeitet)</span>');
}
}
async function toggleLike(postId, btn) { async function toggleLike(postId, btn) {
const res = await fetch('/gruppen/' + gruppeId + '/posts/' + postId + '/like', { method:'POST' }); const res = await fetch('/gruppen/' + gruppeId + '/posts/' + postId + '/like', { method:'POST' });
if (!res.ok) return; if (!res.ok) return;
@@ -620,17 +718,25 @@
// ── Compose ── // ── Compose ──
function toggleUmfrage() { function toggleUmfrage(btn) {
const isUmfrage = document.querySelector('input[name="beitragTyp"]:checked')?.value === 'UMFRAGE'; const options = document.getElementById('umfrageOptions');
document.getElementById('umfrageOptions').style.display = isUmfrage ? '' : 'none'; const isShowing = options.style.display !== 'none';
document.getElementById('multiChoiceRow').style.display = isUmfrage ? '' : 'none'; options.style.display = isShowing ? 'none' : '';
const placeholder = document.getElementById('composeText'); const placeholder = document.getElementById('composeText');
placeholder.placeholder = isUmfrage ? 'Frage eingeben…' : 'Was möchtest du teilen?'; placeholder.placeholder = isShowing ? 'Was möchtest du teilen?' : 'Frage eingeben…';
if (isUmfrage && document.getElementById('optionList').children.length === 0) { if (btn) btn.classList.toggle('active', !isShowing);
if (!isShowing && document.getElementById('optionList').children.length === 0) {
addOption(); addOption(); addOption(); addOption();
} }
} }
function resetUmfrage() {
document.getElementById('umfrageOptions').style.display = 'none';
document.getElementById('optionList').innerHTML = '';
document.getElementById('composeText').placeholder = 'Was möchtest du teilen?';
document.getElementById('umfrageBtn').classList.remove('active');
}
function addOption() { function addOption() {
const list = document.getElementById('optionList'); const list = document.getElementById('optionList');
const idx = list.children.length; const idx = list.children.length;
@@ -642,11 +748,12 @@
async function submitPost() { async function submitPost() {
const text = document.getElementById('composeText').value.trim(); const text = document.getElementById('composeText').value.trim();
const hasUmfrage = document.getElementById('umfrageOptions').style.display !== 'none';
if (!text) return; if (!text) return;
const beitragTyp = document.querySelector('input[name="beitragTyp"]:checked')?.value || 'TEXT'; const beitragTyp = hasUmfrage ? 'UMFRAGE' : 'TEXT';
let optionen = null; let optionen = null;
let multiChoice = null; let multiChoice = null;
if (beitragTyp === 'UMFRAGE') { if (hasUmfrage) {
optionen = Array.from(document.querySelectorAll('#optionList input')).map(i => i.value.trim()).filter(v => v); optionen = Array.from(document.querySelectorAll('#optionList input')).map(i => i.value.trim()).filter(v => v);
if (optionen.length < 2) { showModal('Hinweis', 'Bitte mindestens 2 Optionen eingeben.', [{ label: 'OK' }]); return; } if (optionen.length < 2) { showModal('Hinweis', 'Bitte mindestens 2 Optionen eingeben.', [{ label: 'OK' }]); return; }
multiChoice = document.getElementById('multiChoice').checked; multiChoice = document.getElementById('multiChoice').checked;
@@ -659,9 +766,7 @@
}); });
if (res.ok || res.status === 201) { if (res.ok || res.status === 201) {
document.getElementById('composeText').value = ''; document.getElementById('composeText').value = '';
document.getElementById('optionList').innerHTML = ''; resetUmfrage();
document.querySelector('input[name="beitragTyp"][value="TEXT"]').checked = true;
toggleUmfrage();
composeBilderArr = []; composeBilderArr = [];
renderComposeThumbs(); renderComposeThumbs();
await loadPosts(); await loadPosts();
@@ -938,28 +1043,22 @@
// ── Post dialog ── // ── Post dialog ──
let lbPostId = null;
async function openPostDialog(postId) { async function openPostDialog(postId) {
lbPostId = postId;
const post = allPosts.find(p => p.beitragId === postId); const post = allPosts.find(p => p.beitragId === postId);
if (!post) return; if (!post) return;
renderLbPost(post); renderLbPost(post);
document.getElementById('postDialog').classList.add('open'); _lbSetupContent(postId, 'gp', post.bilder);
await loadLbComments(); document.getElementById('postLightbox').classList.add('open');
document.body.style.overflow = 'hidden';
await loadLbComments(postId, 'GROUP');
document.getElementById('lbCommentInput').focus(); document.getElementById('lbCommentInput').focus();
} }
function closePostDialog() {
document.getElementById('postDialog').classList.remove('open');
lbPostId = null;
}
function renderLbPost(p) { function renderLbPost(p) {
const canDelete = (myRole === 'ADMIN' || p.authorId === myId); const canDelete = (myRole === 'ADMIN' || p.authorId === myId);
const av = p.authorPicture ? `<img src="data:image/png;base64,${p.authorPicture}" alt="">` : '◉'; const av = p.authorPicture ? `<img src="data:image/png;base64,${p.authorPicture}" alt="">` : '◉';
const bildHtml = bilderCarousel(p.bilder);
let body = ''; let umfrageHtml = '';
if (p.beitragTyp === 'UMFRAGE') { if (p.beitragTyp === 'UMFRAGE') {
const total = p.optionen.reduce((s, o) => s + o.stimmenCount, 0); const total = p.optionen.reduce((s, o) => s + o.stimmenCount, 0);
const bars = p.optionen.map(o => { const bars = p.optionen.map(o => {
@@ -973,19 +1072,23 @@
</div> </div>
</div>`; </div>`;
}).join(''); }).join('');
body = `<div style="font-weight:600;margin-bottom:0.5rem;">${esc(p.text)}</div>${bildHtml}${bars}<div class="umfrage-total">${total} Stimme${total !== 1 ? 'n' : ''} gesamt${p.multiChoice?' · Multi-Choice':''}</div>`; umfrageHtml = bars + `<div class="umfrage-total">${total} Stimme${total !== 1 ? 'n' : ''} gesamt${p.multiChoice?' · Multi-Choice':''}</div>`;
} else {
body = `<div class="post-text">${esc(p.text)}</div>${bildHtml}`;
} }
document.getElementById('lbPostContent').innerHTML = `
const textStyle = p.beitragTyp === 'UMFRAGE' ? ' style="font-weight:600;margin-bottom:0.5rem;"' : '';
document.getElementById('lbPostBody').innerHTML = `
<div class="post-header"> <div class="post-header">
<div class="post-avatar">${av}</div> <div class="post-avatar"><a href="/community/benutzer.html?userId=${p.authorId}" style="display:contents;">${av}</a></div>
<div> <div>
<div class="post-author"><a href="/community/benutzer.html?userId=${p.authorId}" style="color:inherit;text-decoration:none;">${esc(p.authorName)}</a></div> <div class="post-author"><a href="/community/benutzer.html?userId=${p.authorId}" style="color:inherit;text-decoration:none;">${esc(p.authorName)}</a></div>
<div class="post-date">${fmtDate(p.createdAt)}</div> <div class="post-date">${fmtDate(p.createdAt)}</div>
</div> </div>
</div> </div>
${body} <div id="gpva-${p.beitragId}">
<div class="post-text"${textStyle}>${renderTextWithHashtags(p.text)}</div>
<div id="gpbi-${p.beitragId}"></div>
</div>
<div id="gpum-${p.beitragId}">${umfrageHtml}</div>
<div class="post-actions" style="margin-top:0.75rem;"> <div class="post-actions" style="margin-top:0.75rem;">
<button class="post-action-btn ${p.likedByMe?'active':''}" onclick="toggleLikeLb('${p.beitragId}',this)"> <button class="post-action-btn ${p.likedByMe?'active':''}" onclick="toggleLikeLb('${p.beitragId}',this)">
♥ <span id="lb-like-count-${p.beitragId}">${p.likeCount}</span> ♥ <span id="lb-like-count-${p.beitragId}">${p.likeCount}</span>
@@ -1011,41 +1114,7 @@
if (post) { post.likeCount = newCount; post.likedByMe = !isActive; } if (post) { post.likeCount = newCount; post.likedByMe = !isActive; }
} }
async function deleteKommentar(kommentarId) {
await fetch('/social/kommentare/' + kommentarId, { method: 'DELETE' });
await loadLbComments();
}
async function loadLbComments() {
if (!lbPostId) return;
const list = document.getElementById('lbCommentsList');
list.innerHTML = '';
const res = await fetch('/social/kommentare?targetType=GROUP_POST&targetId=' + lbPostId);
if (!res.ok) return;
const kmts = await res.json();
kmts.forEach(k => list.insertAdjacentHTML('beforeend', renderKommentarHtml(k, 'GROUP_POST', lbPostId, { myUserId: myId })));
list.scrollTop = list.scrollHeight;
}
async function submitLbComment() {
if (!lbPostId) return;
const input = document.getElementById('lbCommentInput');
const text = input.value.trim();
if (!text) return;
const res = await fetch('/social/kommentare', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ targetType: 'GROUP_POST', targetId: lbPostId, text })
});
if (res.ok || res.status === 201) {
input.value = '';
await loadLbComments();
const countEl = document.getElementById('kmt-count-' + lbPostId);
if (countEl) countEl.textContent = parseInt(countEl.textContent) + 1;
const post = allPosts.find(p => p.beitragId === lbPostId);
if (post) post.kommentarCount++;
}
}
async function reportPostLb(postId, btn) { async function reportPostLb(postId, btn) {
btn.disabled = true; btn.disabled = true;
@@ -1071,7 +1140,7 @@
if (res.ok || res.status === 204) { if (res.ok || res.status === 204) {
document.getElementById('post-' + postId)?.remove(); document.getElementById('post-' + postId)?.remove();
allPosts = allPosts.filter(p => p.beitragId !== postId); allPosts = allPosts.filter(p => p.beitragId !== postId);
closePostDialog(); closeLb();
if (allPosts.length === 0) { document.getElementById('postsEmpty').style.display = ''; document.getElementById('loadMoreBtn').style.display = 'none'; } if (allPosts.length === 0) { document.getElementById('postsEmpty').style.display = ''; document.getElementById('loadMoreBtn').style.display = 'none'; }
} }
}} }}
@@ -1087,13 +1156,15 @@
if (!res.ok) return; if (!res.ok) return;
await loadPosts(); await loadPosts();
const post = allPosts.find(p => p.beitragId === postId); const post = allPosts.find(p => p.beitragId === postId);
if (post) renderLbPost(post); if (post) {
renderLbPost(post);
_lbSetupContent(postId, 'gp', post.bilder);
}
} }
document.getElementById('postDialog').addEventListener('click', e => { document.getElementById('postLightbox').addEventListener('click', e => {
if (e.target === document.getElementById('postDialog')) closePostDialog(); if (e.target === document.getElementById('postLightbox')) closeLb();
}); });
document.addEventListener('keydown', e => { if (e.key === 'Escape') closePostDialog(); });
init(); init();

View File

@@ -7,6 +7,7 @@
<title>Location xXx Sphere</title> <title>Location xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css"> <link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css"> <link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="/css/community.css">
<style> <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 { 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); } .back-link:hover { color:var(--color-primary); }
@@ -216,6 +217,28 @@
</div> </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 ───────────────────────────────────────────────────── --> <!-- ── Galerie Lightbox ───────────────────────────────────────────────────── -->
<div class="lb" id="lightbox" onclick="closeLightbox()"> <div class="lb" id="lightbox" onclick="closeLightbox()">
<button class="lb-close" onclick="closeLightbox()"></button> <button class="lb-close" onclick="closeLightbox()"></button>
@@ -226,6 +249,8 @@
<script src="/js/icons.js"></script> <script src="/js/icons.js"></script>
<script src="/js/nav.js"></script> <script src="/js/nav.js"></script>
<script src="/js/hashtag.js"></script>
<script src="/js/shared.js"></script>
<script> <script>
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
const locationId = params.get('id'); const locationId = params.get('id');
@@ -303,6 +328,15 @@ async function loadPage() {
isFollowing = !!locDetail.following; isFollowing = !!locDetail.following;
renderPage(); 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) { if (isAdmin) {
const chatWithId = new URLSearchParams(location.search).get('chatWith'); const chatWithId = new URLSearchParams(location.search).get('chatWith');
@@ -315,6 +349,8 @@ async function loadPage() {
} else { } else {
loadEvents(); loadEvents();
} }
await loadLocFeed();
initLocFeedObserver();
} }
function renderPage() { function renderPage() {
@@ -372,6 +408,37 @@ function renderPage() {
</div> </div>
<div class="gallery-grid" id="galleryGrid">${galleryHtml}</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 = ` const eventsSection = `
<div class="section-title"> <div class="section-title">
Veranstaltungen Veranstaltungen
@@ -396,6 +463,7 @@ function renderPage() {
${locHeaderHtml} ${locHeaderHtml}
${hoursHtml} ${hoursHtml}
${gallerySection} ${gallerySection}
${feedSection}
</div> </div>
<div class="tab-panel" id="tab-admins"> <div class="tab-panel" id="tab-admins">
@@ -449,6 +517,7 @@ function renderPage() {
${locHeaderHtml} ${locHeaderHtml}
${hoursHtml} ${hoursHtml}
${gallerySection} ${gallerySection}
${feedSection}
${eventsSection}`; ${eventsSection}`;
} }
} }
@@ -1135,6 +1204,304 @@ async function sendInboxReply() {
} catch { showAlert('Fehler beim Senden.'); input.value = text; } } 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 ────────────────────────────────────────────────────────────────────── // ── Init ──────────────────────────────────────────────────────────────────────
loadPage(); loadPage();
</script> </script>

View File

@@ -0,0 +1,104 @@
/* ── Tabs ── */
.tabs{display:flex;gap:0;margin-bottom:1.5rem;border-bottom:1px solid var(--color-secondary)}
.tab-btn{background:none;border:none;border-bottom:3px solid transparent;border-radius:0;padding:0.6rem 1.25rem;font-size:0.95rem;font-weight:600;color:var(--color-muted);cursor:pointer;margin-bottom:-1px;transition:color 0.15s,border-color 0.15s}
.tab-btn:hover{color:var(--color-text);background:none}
.tab-btn.active{color:var(--color-primary);border-bottom-color:var(--color-primary)}
.tab-panel{display:none}
.tab-panel.active{display:block}
/* ── Post-Compose ── */
.post-compose{background:var(--color-card);border:1px solid var(--color-secondary);border-radius:10px;padding:1rem;margin-bottom:1rem;transition:border-color 0.15s}
.post-compose.drag-over{border-color:var(--color-primary);background:rgba(var(--color-primary-rgb,180,0,60),0.06)}
.post-compose textarea{width:100%;padding:0.6rem 0.85rem;border:1px solid var(--color-secondary);border-radius:6px;background:var(--color-secondary);color:var(--color-text);font-size:0.95rem;outline:none;transition:border-color 0.2s;resize:vertical;min-height:70px;box-sizing:border-box}
.post-compose textarea:focus{border-color:var(--color-primary)}
.compose-thumbs{display:none;flex-wrap:wrap;gap:0.5rem;margin-top:0.5rem}
.compose-thumb{position:relative;width:64px;height:64px;flex-shrink:0}
.compose-thumb img{width:64px;height:64px;object-fit:cover;border-radius:6px;display:block}
.compose-thumb-remove{position:absolute;top:-5px;right:-5px;background:rgba(0,0,0,0.7);border:none;color:#fff;width:18px;height:18px;border-radius:50%;font-size:0.65rem;cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0;margin:0;width:auto;line-height:1}
.compose-footer{display:flex;justify-content:space-between;align-items:center;margin-top:0.75rem;flex-wrap:wrap;gap:0.5rem}
.compose-action-btn{background:none;border:1px solid var(--color-secondary);color:var(--color-muted);border-radius:6px;padding:0.35rem 0.6rem;font-size:0.95rem;cursor:pointer;margin:0;width:auto;transition:border-color 0.15s,color 0.15s}
.compose-action-btn:hover{border-color:var(--color-primary);color:var(--color-primary);background:none}
.compose-action-btn.active{border-color:var(--color-primary);color:var(--color-primary)}
label.compose-action-btn{display:inline-flex;align-items:center}
.multi-toggle{font-size:0.85rem;display:flex;align-items:center;gap:0.4rem}
.privacy-toggle{font-size:0.85rem;display:flex;align-items:center;gap:0.4rem}
/* ── Umfrage-Compose ── */
.umfrage-options{margin-top:0.5rem}
.umfrage-option-row{display:flex;gap:0.5rem;margin-bottom:0.4rem}
.umfrage-option-row input{flex:1}
.umfrage-option-row button{width:auto;margin:0;padding:0.3rem 0.6rem;font-size:0.8rem}
/* ── Post-Card ── */
.post-card{background:var(--color-card);border:1px solid var(--color-secondary);border-radius:10px;padding:1rem;margin-bottom:0.9rem}
.post-card.clickable{cursor:pointer;transition:border-color 0.15s}
.post-card.clickable:hover{border-color:var(--color-primary)}
.post-header{display:flex;align-items:center;gap:0.7rem;margin-bottom:0.6rem}
.post-avatar{width:36px;height:36px;border-radius:50%;background:var(--color-secondary);display:flex;align-items:center;justify-content:center;font-size:0.95rem;flex-shrink:0;overflow:hidden}
.post-avatar img{width:100%;height:100%;object-fit:cover}
.post-author{font-weight:600;font-size:0.9rem}
.post-meta{font-size:0.75rem;color:var(--color-muted)}
.post-date{font-size:0.75rem;color:var(--color-muted);margin-left:auto}
.post-text{font-size:0.95rem;line-height:1.5;white-space:pre-wrap;word-break:break-word}
.post-bild{width:100%;max-height:400px;object-fit:contain;border-radius:6px;margin-top:0.5rem;display:block}
.post-actions{display:flex;gap:1rem;margin-top:0.75rem;align-items:center;flex-wrap:wrap}
.post-action-btn{background:none;border:none;color:var(--color-muted);cursor:pointer;font-size:0.85rem;padding:0;display:flex;align-items:center;gap:0.3rem;margin:0;width:auto}
.post-action-btn:hover{color:var(--color-primary);background:none}
.post-action-btn.active{color:var(--color-primary)}
.post-delete{margin-left:auto}
.post-delete:hover{color:#c0392b !important}
/* ── Umfrage-Bars ── */
.umfrage-option-bar{margin:0.3rem 0;cursor:pointer;border-radius:6px;overflow:hidden;border:1px solid var(--color-secondary);position:relative;transition:border-color 0.15s}
.umfrage-option-bar:hover{border-color:var(--color-primary)}
.umfrage-option-bar.voted{border-color:var(--color-primary)}
.umfrage-bar-fill{position:absolute;inset:0;background:rgba(var(--color-primary-rgb,180,0,60),0.15);transition:width 0.4s}
.umfrage-bar-content{position:relative;display:flex;justify-content:space-between;padding:0.45rem 0.75rem;font-size:0.88rem}
.umfrage-total{font-size:0.78rem;color:var(--color-muted);margin-top:0.3rem}
/* ── Gruppen-Badge / Diverse ── */
.gruppe-badge{display:inline-flex;align-items:center;gap:0.3rem;font-size:0.75rem;color:var(--color-muted);background:var(--color-secondary);border-radius:4px;padding:0.15rem 0.45rem;margin-top:0.1rem}
.gruppe-badge a{color:inherit;text-decoration:none}
.gruppe-badge a:hover{color:var(--color-primary)}
.empty-hint{color:var(--color-muted);font-size:0.9rem;margin-top:0.5rem}
.sentinel{height:1px}
/* ── Lightbox ── */
.lightbox{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.88);z-index:700;align-items:center;justify-content:center}
.lightbox.open{display:flex}
/* Max-Breite: 1024px Bild + 300px Kommentare + Padding; Max-Höhe: 1024px Bild + Header/Text */
.lb-layout{display:flex;max-width:min(1400px,calc(100vw - 2rem));width:95vw;height:min(90vh,1200px);background:var(--color-card);border-radius:12px;overflow:hidden;position:relative}
/* Post-Seite als Flex-Spalte damit das Bild den Platz füllt */
.lb-post-side{flex:1;display:flex;flex-direction:column;overflow:hidden;padding:1.25rem;border-right:1px solid var(--color-secondary);min-width:0}
.lb-post-side>*{flex-shrink:0}
.lb-post-side>.post-header{margin-bottom:0.75rem}
/* View-Area (va): wächst, enthält Bild + Text */
.lb-va{flex:1!important;min-height:0;display:flex;flex-direction:column;gap:0.4rem}
/* Bild-Container: füllt verbleibende Höhe */
.lb-ic{flex:1;min-height:0;position:relative;overflow:hidden;border-radius:4px}
.lb-ic .post-carousel{position:absolute;inset:0;display:flex;flex-direction:column}
.lb-ic .car-slide{display:none}
.lb-ic .car-slide.active{flex:1;min-height:0;display:flex;align-items:center;justify-content:center}
.lb-ic .car-slide img{max-width:100%;max-height:100%;width:auto;height:auto;object-fit:contain;display:block}
.lb-ic .car-indicator{flex-shrink:0;text-align:center;font-size:0.75rem;color:var(--color-muted);padding:0.2rem 0}
/* Post-Text: scrollbar bei langem Inhalt */
.lb-text{flex-shrink:0!important;max-height:100px;overflow-y:auto;font-size:0.95rem;line-height:1.5;white-space:pre-wrap;word-break:break-word}
.lb-close{position:absolute;top:0.6rem;right:0.6rem;background:rgba(0,0,0,0.55);border:none;color:#fff;font-size:1.1rem;width:2rem;height:2rem;border-radius:50%;cursor:pointer;z-index:10;display:flex;align-items:center;justify-content:center;padding:0;margin:0}
.lb-comments-panel{width:300px;flex-shrink:0;display:flex;flex-direction:column}
.lb-comments-header{font-size:0.78rem;font-weight:600;color:var(--color-muted);text-transform:uppercase;letter-spacing:0.06em;padding:0.7rem 1rem;border-bottom:1px solid var(--color-secondary);flex-shrink:0}
.lb-comments-list{flex:1;overflow-y:auto;padding:0.75rem}
.lb-comment-compose{padding:0.75rem;border-top:1px solid var(--color-secondary);display:flex;flex-direction:column;gap:0.5rem;flex-shrink:0}
.lb-comment-compose textarea{width:100%;font-size:0.85rem;padding:0.35rem 0.6rem;resize:none;background:var(--color-secondary);border:1px solid var(--color-secondary);border-radius:6px;color:var(--color-text);font-family:inherit;outline:none;transition:border-color 0.2s;box-sizing:border-box}
.lb-comment-compose textarea:focus{border-color:var(--color-primary)}
.lb-comment-compose-actions{display:flex;gap:0.5rem;justify-content:flex-end}
.lb-comment-compose button{width:auto;margin:0;padding:0.35rem 0.75rem;font-size:0.8rem}
@media(max-width:650px){.lb-layout{flex-direction:column;height:95vh}.lb-post-side{border-right:none;border-bottom:1px solid var(--color-secondary);flex:0 0 58vh}.lb-comments-panel{width:100%;flex:1}}
/* ── Text-only Lightbox (kein Bild → Kommentare unterhalb, volle Breite) ── */
.lb-text-only{flex-direction:column;width:min(680px,calc(100vw - 2rem));height:min(680px,calc(100vw - 2rem),90vh)}
.lb-text-only .lb-post-side{flex:0 0 auto;border-right:none;border-bottom:1px solid var(--color-secondary);overflow-y:auto;max-height:55%}
.lb-text-only .lb-va{flex:0 0 auto!important;min-height:unset}
.lb-text-only .lb-text{max-height:none!important}
.lb-text-only .lb-comments-panel{width:100%;flex:1;min-height:0;display:flex;flex-direction:column}
.lb-text-only .lb-comments-list{flex:1;overflow-y:auto}
.lb-text-only .lb-comment-compose{flex-shrink:0}

View File

@@ -19,6 +19,14 @@
.car-next{right:0.3rem} .car-next{right:0.3rem}
.car-indicator{text-align:center;font-size:0.75rem;color:var(--color-muted);margin-top:0.25rem} .car-indicator{text-align:center;font-size:0.75rem;color:var(--color-muted);margin-top:0.25rem}
/* ── Bilder-Grid (nur im Feed) ── */
.post-img-grid{display:grid;gap:2px;margin:0.5rem auto 0;border-radius:6px;overflow:hidden;flex-shrink:0}
.pig-item{position:relative;overflow:hidden;min-width:0;min-height:0}
.pig-item img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block}
.pig-contain img{object-fit:contain}
.pig-dark{background:#111}.pig-dark .pig-item{background:#111}
.pig-more{position:absolute;inset:0;background:rgba(0,0,0,0.55);display:flex;align-items:center;justify-content:center;font-size:1.8rem;font-weight:700;color:#fff;pointer-events:none}
/* ── Like / Löschen-Buttons ── */ /* ── Like / Löschen-Buttons ── */
.btn-like{background:none;border:1px solid rgba(255,255,255,0.15);border-radius:20px;padding:0.2rem 0.65rem;color:var(--color-muted);font-size:0.78rem;cursor:pointer;display:inline-flex;align-items:center;gap:0.3rem;margin:0;width:auto;transition:border-color 0.15s,color 0.15s} .btn-like{background:none;border:1px solid rgba(255,255,255,0.15);border-radius:20px;padding:0.2rem 0.65rem;color:var(--color-muted);font-size:0.78rem;cursor:pointer;display:inline-flex;align-items:center;gap:0.3rem;margin:0;width:auto;transition:border-color 0.15s,color 0.15s}
.btn-like:hover,.btn-like.liked{border-color:var(--color-primary);color:var(--color-primary)} .btn-like:hover,.btn-like.liked{border-color:var(--color-primary);color:var(--color-primary)}
@@ -110,21 +118,117 @@ document.addEventListener('click', e => {
} }
}); });
// ── Bild-Karussell ──────────────────────────────────────────────────────────── // ── Bild-Karussell (Lightbox/Detail-Ansicht) ─────────────────────────────────
function bilderCarousel(bilder) { function bilderCarousel(bilder) {
if (!bilder || bilder.length === 0) return ''; if (!bilder || bilder.length === 0) return '';
if (bilder.length === 1) {
return `<div style="margin-top:0.5rem;"><img class="post-bild" src="data:image/jpeg;base64,${bilder[0]}" alt=""></div>`;
}
const slides = bilder.map((b, i) => const slides = bilder.map((b, i) =>
`<div class="car-slide${i === 0 ? ' active' : ''}"><img class="post-bild" src="data:image/jpeg;base64,${b}" alt=""></div>` `<div class="car-slide${i === 0 ? ' active' : ''}"><img class="post-bild" src="data:image/jpeg;base64,${b}" alt=""></div>`
).join(''); ).join('');
return `<div class="post-carousel"> const nav = bilder.length > 1
${slides} ? `<button class="car-btn car-prev" onclick="event.stopPropagation();carNav(this,-1)">&#8249;</button>
<button class="car-btn car-prev" onclick="event.stopPropagation();carNav(this,-1)">&#8249;</button>
<button class="car-btn car-next" onclick="event.stopPropagation();carNav(this,1)">&#8250;</button> <button class="car-btn car-next" onclick="event.stopPropagation();carNav(this,1)">&#8250;</button>
<div class="car-indicator"><span class="car-cur">1</span>/${bilder.length}</div> <div class="car-indicator"><span class="car-cur">1</span>&#8202;/&#8202;${bilder.length}</div>`
: '';
return `<div class="post-carousel">${slides}${nav}</div>`;
}
/** Richtet die Lightbox-Inhalte ein:
* View-Area bekommt Flex-Layout damit das Bild den Platz füllt
* Bild-Container bekommt lb-ic-Klasse + Carousel (für alle Bildanzahlen)
* Post-Text bekommt scrollbare Höhenbegrenzung
*/
function _lbSetupContent(postId, prefix, bilder) {
const body = document.getElementById('lbPostBody');
const va = body.querySelector(`#${prefix}va-${postId}`);
if (va) va.classList.add('lb-va');
const hasImages = bilder && bilder.length > 0;
const pbi = body.querySelector(`#${prefix}bi-${postId}`);
if (pbi) {
if (hasImages) {
pbi.classList.add('lb-ic');
pbi.innerHTML = bilderCarousel(bilder);
} else {
pbi.style.display = 'none';
}
}
if (va) va.querySelector('.post-text')?.classList.add('lb-text');
// Text-only layout: kein Bild → Kommentare unterhalb, volle Breite
const layout = document.querySelector('#postLightbox .lb-layout');
if (layout) layout.classList.toggle('lb-text-only', !hasImages);
}
// ── Bilder-Grid (Feed-Karten, orientierungsabhängig) ──────────────────────────
const POST_IMG_SIZE = 500; // px — Breite und Höhe des Bild-Containers
let _pigSeq = 0;
const _pigStore = new Map(); // id → bilder[]
function bilderGrid(bilder) {
if (!bilder || bilder.length === 0) return '';
const S = POST_IMG_SIZE;
const id = 'pig-' + (++_pigSeq);
if (bilder.length === 1) {
// Längere Seite = S, kürzere letterboxed
return `<div class="post-img-grid pig-contain" id="${id}" style="width:${S}px;height:${S}px;grid-template-columns:1fr;grid-template-rows:1fr;">
<div class="pig-item pig-contain"><img src="data:image/jpeg;base64,${bilder[0]}" alt=""></div>
</div>`; </div>`;
}
// 2+ Bilder: Orientierung des ersten Bilds bestimmt das Layout → deferred init
_pigStore.set(id, bilder);
return `<div class="post-img-grid" id="${id}" style="width:${S}px;height:${S}px;">` +
`<img src="data:image/jpeg;base64,${bilder[0]}" style="display:none;position:absolute;" alt="" onload="_pigInit('${id}',this)">` +
`</div>`;
}
function _pigInit(id, probe) {
const bilder = _pigStore.get(id);
if (!bilder) return;
_pigStore.delete(id);
const grid = document.getElementById(id);
if (!grid) return;
const land = probe.naturalWidth >= probe.naturalHeight; // quer = landscape
const n = bilder.length;
const extra = n - 3;
const moreHtml = extra > 0 ? `<div class="pig-more">+${extra}</div>` : '';
grid.innerHTML = ''; // Probe-Bild entfernen
// Fraktionale Werte → Browser berücksichtigt gap automatisch, Trennstrich immer genau in der Mitte
if (n === 2) {
if (land) {
// Quer-erstes Bild → beide übereinander (Trennstrich horizontal in der Mitte)
grid.style.gridTemplateColumns = '1fr';
grid.style.gridTemplateRows = '1fr 1fr';
} else {
// Hochkant-erstes Bild → beide nebeneinander (Trennstrich vertikal in der Mitte)
grid.style.gridTemplateColumns = '1fr 1fr';
grid.style.gridTemplateRows = '1fr';
}
grid.insertAdjacentHTML('beforeend',
`<div class="pig-item"><img src="data:image/jpeg;base64,${bilder[0]}" alt=""></div>` +
`<div class="pig-item"><img src="data:image/jpeg;base64,${bilder[1]}" alt=""></div>`);
} else {
// 3+ Bilder (ab 4 gleiche Darstellung wie bei 3, +N-Overlay auf Zelle 3)
// Dunkler Hintergrund nur hier, damit der Spalt zwischen den Zellen nicht auffällt
grid.classList.add('pig-dark');
grid.style.gridTemplateColumns = '1fr 1fr';
grid.style.gridTemplateRows = '1fr 1fr';
if (land) {
// Quer → Bild 1 oben (volle Breite), Bilder 2+3 nebeneinander unten
grid.insertAdjacentHTML('beforeend',
`<div class="pig-item" style="grid-column:1/3"><img src="data:image/jpeg;base64,${bilder[0]}" alt=""></div>` +
`<div class="pig-item"><img src="data:image/jpeg;base64,${bilder[1]}" alt=""></div>` +
`<div class="pig-item"><img src="data:image/jpeg;base64,${bilder[2]}" alt="">${moreHtml}</div>`);
} else {
// Hochkant → Bild 1 links (volle Höhe), Bilder 2+3 übereinander rechts
grid.insertAdjacentHTML('beforeend',
`<div class="pig-item" style="grid-row:1/3"><img src="data:image/jpeg;base64,${bilder[0]}" alt=""></div>` +
`<div class="pig-item"><img src="data:image/jpeg;base64,${bilder[1]}" alt=""></div>` +
`<div class="pig-item"><img src="data:image/jpeg;base64,${bilder[2]}" alt="">${moreHtml}</div>`);
}
}
} }
function carNav(btn, dir) { function carNav(btn, dir) {
@@ -209,6 +313,7 @@ async function loadReplies(kommentarId) {
const res = await fetch(`/social/kommentare?targetType=KOMMENTAR&targetId=${kommentarId}`); const res = await fetch(`/social/kommentare?targetType=KOMMENTAR&targetId=${kommentarId}`);
const replies = await res.json(); const replies = await res.json();
const section = document.getElementById('replies-' + kommentarId); const section = document.getElementById('replies-' + kommentarId);
replies.reverse();
section.innerHTML = (replies.length === 0 section.innerHTML = (replies.length === 0
? '<p style="color:var(--color-muted);font-size:0.78rem;margin-bottom:0.35rem;">Noch keine Antworten.</p>' ? '<p style="color:var(--color-muted);font-size:0.78rem;margin-bottom:0.35rem;">Noch keine Antworten.</p>'
: replies.map(r => renderReplyHtml(r, kommentarId)).join('')) : replies.map(r => renderReplyHtml(r, kommentarId)).join(''))
@@ -235,3 +340,237 @@ async function deleteReply(replyId, parentId) {
await fetch('/social/kommentare/' + replyId, { method: 'DELETE' }); await fetch('/social/kommentare/' + replyId, { method: 'DELETE' });
await loadReplies(parentId); await loadReplies(parentId);
} }
// ─────────────────────────────────────────────────────────────────────────────
// Gemeinsame Lightbox (standardisierte IDs: #postLightbox, #lbPostBody,
// #lbCommentsList, #lbCommentInput auf allen Feed-Seiten gleich)
// ─────────────────────────────────────────────────────────────────────────────
let _lbMyUserId = null;
let _lbPostId = null;
let _lbPostType = null;
/** Muss nach dem Login mit der eigenen userId aufgerufen werden. */
function initLb(userId) { _lbMyUserId = userId; }
function closeLb() {
document.getElementById('postLightbox')?.classList.remove('open');
document.body.style.overflow = '';
_lbPostId = null; _lbPostType = null;
}
// Escape schließt, Pfeiltasten navigieren das Karussell
document.addEventListener('keydown', e => {
const lb = document.getElementById('postLightbox');
if (!lb?.classList.contains('open')) return;
if (e.key === 'Escape') { closeLb(); return; }
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
const car = lb.querySelector('.post-carousel');
if (!car) return;
const slides = Array.from(car.querySelectorAll('.car-slide'));
if (slides.length <= 1) return;
const cur = slides.findIndex(s => s.classList.contains('active'));
const next = (cur + (e.key === 'ArrowLeft' ? -1 : 1) + slides.length) % slides.length;
slides[cur].classList.remove('active');
slides[next].classList.add('active');
const ind = car.querySelector('.car-cur');
if (ind) ind.textContent = next + 1;
});
async function loadLbComments(postId, postType) {
_lbPostId = postId; _lbPostType = postType;
const targetType = postType === 'GROUP' ? 'GROUP_POST' : 'FEED_POST';
try {
const res = await fetch(`/social/kommentare?targetType=${targetType}&targetId=${postId}`);
const comments = await res.json();
comments.reverse();
document.getElementById('lbCommentsList').innerHTML = comments.length === 0
? '<p style="color:var(--color-muted);font-size:0.82rem;margin:0.4rem;">Noch keine Kommentare.</p>'
: comments.map(k => renderKommentarHtml(k, targetType, postId, { myUserId: _lbMyUserId })).join('');
} catch (_) {}
}
async function postLbComment() {
if (!_lbPostId) return;
const input = document.getElementById('lbCommentInput');
const text = input.value.trim();
if (!text) return;
const targetType = _lbPostType === 'GROUP' ? 'GROUP_POST' : 'FEED_POST';
await fetch('/social/kommentare', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetType, targetId: _lbPostId, text })
});
input.value = '';
await loadLbComments(_lbPostId, _lbPostType);
const kcEl = document.getElementById('kc-' + _lbPostId) || document.getElementById('hkc-' + _lbPostId);
if (kcEl) kcEl.textContent = parseInt(kcEl.textContent || '0') + 1;
}
async function deleteKommentar(kommentarId, targetType, targetId) {
await fetch('/social/kommentare/' + kommentarId, { method: 'DELETE' });
await loadLbComments(targetId, _lbPostType);
}
// ─────────────────────────────────────────────────────────────────────────────
// Bild-Verarbeitung (Compose & Edit)
// ─────────────────────────────────────────────────────────────────────────────
/** Liest eine Bilddatei, skaliert auf max. 1024px, komprimiert auf JPEG 85 %
* und hängt den Base64-String an bilderArr an, dann ruft renderFn() auf. */
function processImageFile(file, bilderArr, renderFn) {
if (!file || !file.type.startsWith('image/')) return;
const reader = new FileReader();
reader.onload = e => {
const img = new Image();
img.onload = () => {
const MAX = 1024, canvas = document.createElement('canvas');
const s = Math.min(MAX / img.width, MAX / img.height, 1);
canvas.width = Math.round(img.width * s); canvas.height = Math.round(img.height * s);
canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height);
bilderArr.push(canvas.toDataURL('image/jpeg', 0.85).split(',')[1]);
renderFn();
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
}
/** Rendert Vorschau-Thumbnails in containerId; rmCallback(idx) wird beim ✕ aufgerufen. */
function renderBilderThumbs(bilderArr, containerId, rmCallback) {
const c = document.getElementById(containerId);
if (!c) return;
c.innerHTML = '';
bilderArr.forEach((b, i) => {
const div = document.createElement('div');
div.className = 'compose-thumb';
const img = document.createElement('img');
img.src = `data:image/jpeg;base64,${b}`;
const btn = document.createElement('button');
btn.className = 'compose-thumb-remove';
btn.textContent = '✕'; btn.title = 'Entfernen';
btn.onclick = ev => { ev.stopPropagation(); rmCallback(i); };
div.appendChild(img); div.appendChild(btn);
c.appendChild(div);
});
c.style.display = bilderArr.length > 0 ? 'flex' : 'none';
}
// ─────────────────────────────────────────────────────────────────────────────
// Post-Bearbeitung (gemeinsam, über Präfix parametrisiert)
// Präfixe: 'p' (feed), 'hp' (userhome), 'gp' (gruppe)
// IDs-Muster: ${prefix}va-, ${prefix}bi-, ${prefix}ea-, ${prefix}um-,
// ${prefix}m-, ${prefix}et-, ${prefix}et-tb-, ${prefix}eo-, ${prefix}mc-
// ─────────────────────────────────────────────────────────────────────────────
/**
* cfg: { postId, prefix, data, editBilderMap,
* saveFn, cancelFn, addImgFn, addOptionFn, rmImgFn }
* Alle Fn-Namen sind Strings (globale Funktionsnamen auf der jeweiligen Seite).
*/
function startPostEdit(cfg) {
const { postId, prefix, data, editBilderMap, saveFn, cancelFn, addImgFn, addOptionFn, rmImgFn } = cfg;
editBilderMap.set(postId, [...(data.bilder || [])]);
document.getElementById(`${prefix}va-${postId}`).style.display = 'none';
document.getElementById(`${prefix}um-${postId}`).style.display = 'none';
const isUmfrage = data.beitragTyp === 'UMFRAGE';
const optionenHtml = isUmfrage
? `<div id="${prefix}eo-${postId}" style="margin-top:0.5rem;">${(data.optionen || []).map(o =>
`<div class="umfrage-option-row">
<input type="text" value="${esc(o.text)}" maxlength="200" data-option-id="${o.optionId}"
style="flex:1;padding:0.4rem 0.6rem;border:1px solid var(--color-secondary);border-radius:6px;background:var(--color-secondary);color:var(--color-text);font-size:0.9rem;"
onmousedown="event.stopPropagation()" onclick="event.stopPropagation()" onkeydown="event.stopPropagation()">
<button onmousedown="event.stopPropagation()" onclick="event.stopPropagation();this.closest('.umfrage-option-row').remove()" style="width:auto;margin:0;padding:0.3rem 0.6rem;font-size:0.8rem;">✕</button>
</div>`).join('')}
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:0.3rem;" onclick="event.stopPropagation()">
<button onmousedown="event.stopPropagation()" onclick="event.stopPropagation();${addOptionFn}('${postId}')" style="width:auto;margin:0;padding:0.3rem 0.75rem;font-size:0.8rem;">+ Option</button>
<label class="multi-toggle" onmousedown="event.stopPropagation()" onclick="event.stopPropagation()">
<input type="checkbox" id="${prefix}mc-${postId}" ${data.multiChoice ? 'checked' : ''}> Mehrfachauswahl möglich
</label>
</div>
</div>`
: '';
const actionRow = `<div style="display:flex;gap:0.5rem;align-items:center;margin-top:0.5rem;" onclick="event.stopPropagation()">
<label class="compose-action-btn" title="Fotos hinzufügen">📷
<input type="file" accept="image/*" multiple style="display:none;" onchange="event.stopPropagation();${addImgFn}(this,'${postId}')">
</label>
<button onclick="event.stopPropagation();${saveFn}('${postId}')" style="width:auto;margin:0;">Speichern</button>
<button onclick="event.stopPropagation();${cancelFn}('${postId}')" style="width:auto;margin:0;background:var(--color-secondary);color:var(--color-text);">Abbrechen</button>
</div>`;
const ea = document.getElementById(`${prefix}ea-${postId}`);
ea.style.display = ''; ea.onclick = e => e.stopPropagation();
ea.innerHTML = `<textarea id="${prefix}et-${postId}" style="width:100%;box-sizing:border-box;padding:0.6rem;border:1px solid var(--color-secondary);border-radius:6px;background:var(--color-secondary);color:var(--color-text);font-size:0.95rem;resize:vertical;min-height:70px;" onclick="event.stopPropagation()" onkeydown="event.stopPropagation()">${esc(data.text || '')}</textarea>
<div class="compose-thumbs" id="${prefix}et-tb-${postId}" style="margin-top:0.4rem;"></div>
${optionenHtml}${actionRow}`;
_renderEditThumbs(editBilderMap, postId, prefix, rmImgFn);
}
function cancelPostEdit(postId, prefix, editBilderMap) {
document.getElementById(`${prefix}va-${postId}`).style.display = '';
document.getElementById(`${prefix}um-${postId}`).style.display = '';
document.getElementById(`${prefix}ea-${postId}`).style.display = 'none';
editBilderMap.delete(postId);
}
function _renderEditThumbs(editBilderMap, postId, prefix, rmFn) {
const bilder = editBilderMap.get(postId) || [];
const c = document.getElementById(`${prefix}et-tb-${postId}`);
if (!c) return;
c.innerHTML = bilder.map((b, i) =>
`<div class="compose-thumb"><img src="data:image/jpeg;base64,${b}" alt="">
<button class="compose-thumb-remove" onclick="event.stopPropagation();${rmFn}('${postId}',${i})">✕</button></div>`
).join('');
c.style.display = bilder.length > 0 ? 'flex' : 'none';
}
function editAddOptionRow(containerId) {
const container = document.getElementById(containerId);
if (!container) return;
const count = container.querySelectorAll('input[type=text]').length;
const row = document.createElement('div');
row.className = 'umfrage-option-row';
row.innerHTML = `<input type="text" placeholder="Option ${count + 1}" maxlength="200"
style="flex:1;padding:0.4rem 0.6rem;border:1px solid var(--color-secondary);border-radius:6px;background:var(--color-secondary);color:var(--color-text);font-size:0.9rem;"
onmousedown="event.stopPropagation()" onclick="event.stopPropagation()" onkeydown="event.stopPropagation()">
<button onmousedown="event.stopPropagation()" onclick="event.stopPropagation();this.closest('.umfrage-option-row').remove()" style="width:auto;margin:0;padding:0.3rem 0.6rem;font-size:0.8rem;">✕</button>`;
container.insertBefore(row, container.querySelector('div:last-child'));
}
/**
* cfg: { postId, prefix, endpoint, isUmfrage, editBilderMap, onSuccess }
* onSuccess(updated) Seite aktualisiert Cache und DOM.
*/
async function savePostEdit(cfg) {
const { postId, prefix, endpoint, isUmfrage, editBilderMap, onSuccess } = cfg;
const text = document.getElementById(`${prefix}et-${postId}`).value.trim();
if (!text) return;
const bilder = editBilderMap.get(postId) || [];
const optionen = isUmfrage
? Array.from(document.querySelectorAll(`#${prefix}eo-${postId} input[type=text]`))
.map(inp => ({ optionId: inp.dataset.optionId || null, text: inp.value.trim() }))
.filter(o => o.text)
: null;
const multiChoice = isUmfrage ? (document.getElementById(`${prefix}mc-${postId}`)?.checked ?? false) : null;
const res = await fetch(endpoint, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, bilder, optionen, multiChoice })
});
if (!res.ok) return;
editBilderMap.delete(postId);
onSuccess(await res.json());
}
/** Aktualisiert Text, Bilder, Edit-Area, Umfrage und (bearbeitet)-Label im DOM. */
function applyPostEditDom(postId, prefix, updated, umfrageHtml) {
document.getElementById(`${prefix}va-${postId}`).querySelector('.post-text').innerHTML = renderTextWithHashtags(updated.text);
document.getElementById(`${prefix}bi-${postId}`).innerHTML = bilderGrid(updated.bilder);
document.getElementById(`${prefix}va-${postId}`).style.display = '';
document.getElementById(`${prefix}ea-${postId}`).style.display = 'none';
const pum = document.getElementById(`${prefix}um-${postId}`);
if (pum) { pum.innerHTML = umfrageHtml || ''; pum.style.display = ''; }
const meta = document.getElementById(`${prefix}m-${postId}`);
if (meta && !meta.querySelector('.edited-label')) {
meta.insertAdjacentHTML('beforeend', ' <span class="edited-label" style="font-size:0.75rem;color:var(--color-muted);">(bearbeitet)</span>');
}
}

View File

@@ -7,6 +7,7 @@
<title>Home xXx Sphere</title> <title>Home xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css"> <link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css"> <link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="/css/community.css">
<style> <style>
.game-grid { .game-grid {
display: grid; display: grid;
@@ -184,64 +185,9 @@
} }
.friend-req-badge { font-size: 0.65rem; color: var(--color-primary); font-weight: 600; text-align: center; } .friend-req-badge { font-size: 0.65rem; color: var(--color-primary); font-weight: 600; text-align: center; }
/* ── Compose ── */ /* ── Post-Cards (Home: klickbar + Hover) ── */
.post-compose { background:var(--color-card); border:1px solid var(--color-secondary); border-radius:10px; padding:1rem; margin-bottom:1rem; transition:border-color 0.15s; } .post-card { cursor:pointer; transition:border-color 0.15s; }
.post-compose.drag-over { border-color:var(--color-primary); background:rgba(var(--color-primary-rgb,180,0,60),0.06); }
.compose-type { display:flex; gap:1.5rem; margin-bottom:0.75rem; }
.compose-type label { display:flex; align-items:center; gap:0.4rem; font-size:0.9rem; cursor:pointer; }
.post-compose textarea { width:100%; padding:0.6rem 0.85rem; border:1px solid var(--color-secondary); border-radius:6px; background:var(--color-secondary); color:var(--color-text); font-size:0.95rem; outline:none; transition:border-color 0.2s; resize:vertical; min-height:70px; box-sizing:border-box; }
.post-compose textarea:focus { border-color:var(--color-primary); }
.compose-thumbs { display:none; flex-wrap:wrap; gap:0.5rem; margin-top:0.5rem; }
.compose-thumb { position:relative; width:64px; height:64px; flex-shrink:0; }
.compose-thumb img { width:64px; height:64px; object-fit:cover; border-radius:6px; display:block; }
.compose-thumb-remove { position:absolute; top:-5px; right:-5px; background:rgba(0,0,0,0.7); border:none; color:#fff; border-radius:50%; font-size:0.65rem; cursor:pointer; display:flex; align-items:center; justify-content:center; padding:0; margin:0; width:auto; line-height:1; }
.umfrage-options { margin-top:0.5rem; }
.umfrage-option-row { display:flex; gap:0.5rem; margin-bottom:0.4rem; }
.umfrage-option-row input { flex:1; }
.umfrage-option-row button { width:auto; margin:0; padding:0.3rem 0.6rem; font-size:0.8rem; }
.compose-footer { display:flex; justify-content:space-between; align-items:center; margin-top:0.75rem; flex-wrap:wrap; gap:0.5rem; }
.multi-toggle { font-size:0.85rem; display:flex; align-items:center; gap:0.4rem; }
.privacy-toggle { font-size:0.85rem; display:flex; align-items:center; gap:0.4rem; }
.compose-action-btn { background:none; border:1px solid var(--color-secondary); color:var(--color-muted); border-radius:6px; padding:0.35rem 0.6rem; font-size:0.95rem; cursor:pointer; margin:0; width:auto; transition:border-color 0.15s,color 0.15s; }
.compose-action-btn:hover { border-color:var(--color-primary); color:var(--color-primary); background:none; }
label.compose-action-btn { display:inline-flex; align-items:center; }
/* ── Post Cards (1:1 wie Feed) ── */
.post-card { background:var(--color-card); border:1px solid var(--color-secondary); border-radius:10px; padding:1rem; margin-bottom:0.9rem; cursor:pointer; transition:border-color 0.15s; }
.post-card:hover { border-color:var(--color-primary); } .post-card:hover { border-color:var(--color-primary); }
.post-header { display:flex; align-items:center; gap:0.7rem; margin-bottom:0.6rem; }
.post-avatar { width:36px; height:36px; border-radius:50%; background:var(--color-secondary); display:flex; align-items:center; justify-content:center; font-size:0.95rem; flex-shrink:0; overflow:hidden; }
.post-avatar img { width:100%; height:100%; object-fit:cover; }
.post-author { font-weight:600; font-size:0.9rem; }
.post-meta { font-size:0.75rem; color:var(--color-muted); }
.post-text { font-size:0.95rem; line-height:1.5; white-space:pre-wrap; word-break:break-word; }
.post-bild { width:100%; max-height:400px; object-fit:contain; border-radius:6px; margin-top:0.5rem; display:block; }
.post-actions { display:flex; gap:1rem; margin-top:0.75rem; align-items:center; flex-wrap:wrap; }
.post-action-btn { background:none; border:none; color:var(--color-muted); font-size:0.85rem; padding:0; display:flex; align-items:center; gap:0.3rem; margin:0; width:auto; pointer-events:none; }
.post-action-btn.active { color:var(--color-primary); }
.gruppe-badge { display:inline-flex; align-items:center; gap:0.3rem; font-size:0.75rem; color:var(--color-muted); background:var(--color-secondary); border-radius:4px; padding:0.15rem 0.45rem; margin-left:0.3rem; }
.umfrage-option-bar { margin:0.3rem 0; border-radius:6px; overflow:hidden; border:1px solid var(--color-secondary); position:relative; }
.umfrage-option-bar.voted { border-color:var(--color-primary); }
.umfrage-bar-fill { position:absolute; inset:0; background:rgba(var(--color-primary-rgb,180,0,60),0.15); }
.umfrage-bar-content { position:relative; display:flex; justify-content:space-between; padding:0.45rem 0.75rem; font-size:0.88rem; }
.umfrage-total { font-size:0.78rem; color:var(--color-muted); margin-top:0.3rem; }
/* ── Post Lightbox ── */
.lightbox { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.88); z-index:300; align-items:center; justify-content:center; }
.lightbox.open { display:flex; }
.lb-layout { display:flex; max-width:min(1340px, calc(100vw - 2rem)); width:95vw; height:min(90vh, 1100px); background:var(--color-card); border-radius:12px; overflow:hidden; position:relative; }
.lb-post-side { flex:1; overflow-y:auto; padding:1.25rem; border-right:1px solid var(--color-secondary); min-width:0; }
.lb-post-side .post-bild { max-height:1024px; }
.lb-close { position:absolute; top:0.6rem; right:0.6rem; background:rgba(0,0,0,0.55); border:none; color:#fff; font-size:1.1rem; width:2rem; height:2rem; border-radius:50%; cursor:pointer; z-index:10; display:flex; align-items:center; justify-content:center; padding:0; margin:0; }
.lb-comments-panel { width:300px; flex-shrink:0; display:flex; flex-direction:column; }
.lb-comments-header { font-size:0.78rem; font-weight:600; color:var(--color-muted); text-transform:uppercase; letter-spacing:0.06em; padding:0.7rem 1rem; border-bottom:1px solid var(--color-secondary); flex-shrink:0; }
.lb-comments-list { flex:1; overflow-y:auto; padding:0.75rem; }
.lb-comment-compose { padding:0.75rem; border-top:1px solid var(--color-secondary); display:flex; flex-direction:column; gap:0.5rem; flex-shrink:0; }
.lb-comment-compose textarea { width:100%; font-size:0.85rem; padding:0.35rem 0.6rem; resize:none; background:var(--color-secondary); border:1px solid var(--color-secondary); border-radius:6px; color:var(--color-text); font-family:inherit; outline:none; transition:border-color 0.2s; box-sizing:border-box; }
.lb-comment-compose textarea:focus { border-color:var(--color-primary); }
.lb-comment-compose-actions { display:flex; gap:0.5rem; justify-content:flex-end; }
.lb-comment-compose button { width:auto; margin:0; padding:0.35rem 0.75rem; font-size:0.8rem; }
@media (max-width:650px) { .lb-layout { flex-direction:column; height:95vh; } .lb-post-side { border-right:none; border-bottom:1px solid var(--color-secondary); max-height:55vh; } .lb-comments-panel { width:100%; } }
/* ── Spiel starten ── */ /* ── Spiel starten ── */
.start-game-grid { .start-game-grid {
@@ -413,21 +359,19 @@
<!-- Feed Compose + Vorschau --> <!-- Feed Compose + Vorschau -->
<div class="section-label">Feed 📰</div> <div class="section-label">Feed 📰</div>
<div class="post-compose" id="homeCompose"> <div class="post-compose" id="homeCompose">
<div class="compose-type">
<label><input type="radio" name="homeBeitragTyp" value="TEXT" checked onchange="homeToggleUmfrage()"> Text</label>
<label><input type="radio" name="homeBeitragTyp" value="UMFRAGE" onchange="homeToggleUmfrage()"> Umfrage</label>
</div>
<textarea id="homeComposeText" placeholder="Was möchtest du teilen?" rows="3"></textarea> <textarea id="homeComposeText" placeholder="Was möchtest du teilen?" rows="3"></textarea>
<div class="compose-thumbs" id="homeComposeThumbs"></div> <div class="compose-thumbs" id="homeComposeThumbs"></div>
<div class="umfrage-options" id="homeUmfrageOptions" style="display:none;"> <div class="umfrage-options" id="homeUmfrageOptions" style="display:none;">
<div id="homeOptionList"></div> <div id="homeOptionList"></div>
<button onclick="homeAddOption()" style="width:auto;margin:0;padding:0.3rem 0.75rem;font-size:0.8rem;margin-top:0.4rem;">+ Option</button> <div style="display:flex;justify-content:space-between;align-items:center;margin-top:0.5rem;">
<button onclick="homeAddOption()" style="width:auto;margin:0;padding:0.3rem 0.75rem;font-size:0.8rem;">+ Option</button>
<label class="multi-toggle">
<input type="checkbox" id="homeMultiChoice"> Mehrfachauswahl möglich
</label>
</div>
</div> </div>
<div class="compose-footer"> <div class="compose-footer">
<div style="display:flex;gap:1rem;align-items:center;flex-wrap:wrap;"> <div style="display:flex;gap:1rem;align-items:center;flex-wrap:wrap;">
<label class="multi-toggle" id="homeMultiChoiceRow" style="display:none;">
<input type="checkbox" id="homeMultiChoice"> Multi-Choice
</label>
<label class="privacy-toggle"> <label class="privacy-toggle">
<input type="checkbox" id="homeIsPublic"> Öffentlich <input type="checkbox" id="homeIsPublic"> Öffentlich
</label> </label>
@@ -437,6 +381,7 @@
<label class="compose-action-btn" title="Fotos hinzufügen">📷 <label class="compose-action-btn" title="Fotos hinzufügen">📷
<input type="file" id="homeComposeBildFile" accept="image/*" multiple style="display:none;" onchange="homeSelectBilder(this)"> <input type="file" id="homeComposeBildFile" accept="image/*" multiple style="display:none;" onchange="homeSelectBilder(this)">
</label> </label>
<button type="button" id="homeUmfrageBtn" class="compose-action-btn" onclick="homeToggleUmfrage(this)" title="Umfrage hinzufügen">📊</button>
<button onclick="homeSubmitPost()" style="width:auto;margin:0;">Veröffentlichen</button> <button onclick="homeSubmitPost()" style="width:auto;margin:0;">Veröffentlichen</button>
</div> </div>
</div> </div>
@@ -482,6 +427,7 @@
.then(user => { .then(user => {
if (user) { if (user) {
myUserId = user.userId; myUserId = user.userId;
initLb(user.userId);
document.getElementById('greeting').textContent = 'Willkommen zurück, ' + user.name + '!'; document.getElementById('greeting').textContent = 'Willkommen zurück, ' + user.name + '!';
Promise.all([loadActiveGames(user.userId), loadActiveLock()]).then(() => { Promise.all([loadActiveGames(user.userId), loadActiveLock()]).then(() => {
const hasGames = document.getElementById('activeGamesSection').style.display !== 'none'; const hasGames = document.getElementById('activeGamesSection').style.display !== 'none';
@@ -770,15 +716,22 @@
let homeComposeBilder = []; let homeComposeBilder = [];
function homeToggleUmfrage() { function homeToggleUmfrage(btn) {
const isUmfrage = document.querySelector('input[name="homeBeitragTyp"]:checked').value === 'UMFRAGE'; const options = document.getElementById('homeUmfrageOptions');
document.getElementById('homeUmfrageOptions').style.display = isUmfrage ? '' : 'none'; const isShowing = options.style.display !== 'none';
document.getElementById('homeMultiChoiceRow').style.display = isUmfrage ? '' : 'none'; options.style.display = isShowing ? 'none' : '';
if (isUmfrage && document.getElementById('homeOptionList').children.length === 0) { if (btn) btn.classList.toggle('active', !isShowing);
if (!isShowing && document.getElementById('homeOptionList').children.length === 0) {
homeAddOption(); homeAddOption(); homeAddOption(); homeAddOption();
} }
} }
function homeResetUmfrage() {
document.getElementById('homeUmfrageOptions').style.display = 'none';
document.getElementById('homeOptionList').innerHTML = '';
document.getElementById('homeUmfrageBtn').classList.remove('active');
}
function homeAddOption() { function homeAddOption() {
const list = document.getElementById('homeOptionList'); const list = document.getElementById('homeOptionList');
const idx = list.children.length; const idx = list.children.length;
@@ -789,57 +742,28 @@
list.appendChild(row); list.appendChild(row);
} }
function homeSelectBilder(input) {
[...input.files].forEach(f => { if (f.type.startsWith('image/')) homeProcessImage(f); });
input.value = '';
}
function homeProcessImage(file) {
const reader = new FileReader();
reader.onload = e => {
const img = new Image();
img.onload = () => {
const maxSize = 1024;
const canvas = document.createElement('canvas');
const scale = Math.min(maxSize / img.width, maxSize / img.height, 1);
canvas.width = Math.round(img.width * scale);
canvas.height = Math.round(img.height * scale);
canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height);
homeComposeBilder.push(canvas.toDataURL('image/jpeg', 0.85).split(',')[1]);
homeRenderThumbs();
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
}
function homeRenderThumbs() { function homeRenderThumbs() {
const container = document.getElementById('homeComposeThumbs'); renderBilderThumbs(homeComposeBilder, 'homeComposeThumbs', i => {
container.innerHTML = ''; homeComposeBilder.splice(i, 1);
homeComposeBilder.forEach((b, i) => { homeRenderThumbs();
const div = document.createElement('div');
div.className = 'compose-thumb';
div.innerHTML = `<img src="data:image/jpeg;base64,${b}" alt="">
<button class="compose-thumb-remove" onclick="homeRemoveThumb(${i})">✕</button>`;
container.appendChild(div);
}); });
container.style.display = homeComposeBilder.length > 0 ? 'flex' : 'none';
} }
function homeRemoveThumb(idx) { function homeSelectBilder(input) {
homeComposeBilder.splice(idx, 1); [...input.files].forEach(f => processImageFile(f, homeComposeBilder, homeRenderThumbs));
homeRenderThumbs(); input.value = '';
} }
async function homeSubmitPost() { async function homeSubmitPost() {
const text = document.getElementById('homeComposeText').value.trim(); const text = document.getElementById('homeComposeText').value.trim();
const hasUmfrage = document.getElementById('homeUmfrageOptions').style.display !== 'none';
if (!text && homeComposeBilder.length === 0) return; if (!text && homeComposeBilder.length === 0) return;
const beitragTyp = document.querySelector('input[name="homeBeitragTyp"]:checked').value; const beitragTyp = hasUmfrage ? 'UMFRAGE' : 'TEXT';
const multiChoice = document.getElementById('homeMultiChoice').checked; const multiChoice = document.getElementById('homeMultiChoice').checked;
const isPublic = document.getElementById('homeIsPublic').checked; const isPublic = document.getElementById('homeIsPublic').checked;
let optionen = []; let optionen = [];
if (beitragTyp === 'UMFRAGE') { if (hasUmfrage) {
optionen = Array.from(document.getElementById('homeOptionList').querySelectorAll('input')) optionen = Array.from(document.getElementById('homeOptionList').querySelectorAll('input'))
.map(i => i.value.trim()).filter(v => v); .map(i => i.value.trim()).filter(v => v);
if (optionen.length < 2) { alert('Mindestens 2 Optionen erforderlich.'); return; } if (optionen.length < 2) { alert('Mindestens 2 Optionen erforderlich.'); return; }
@@ -856,11 +780,9 @@
document.getElementById('homeComposeText').value = ''; document.getElementById('homeComposeText').value = '';
homeComposeBilder = []; homeComposeBilder = [];
homeRenderThumbs(); homeRenderThumbs();
document.querySelector('input[name="homeBeitragTyp"][value="TEXT"]').checked = true; homeResetUmfrage();
homeToggleUmfrage();
document.getElementById('homeMultiChoice').checked = false; document.getElementById('homeMultiChoice').checked = false;
document.getElementById('homeIsPublic').checked = false; document.getElementById('homeIsPublic').checked = false;
document.getElementById('homeOptionList').innerHTML = '';
// Prepend in Vorschau // Prepend in Vorschau
const feedList = document.getElementById('feedList'); const feedList = document.getElementById('feedList');
@@ -882,7 +804,7 @@
homeCompose.addEventListener('drop', e => { homeCompose.addEventListener('drop', e => {
e.preventDefault(); e.preventDefault();
homeCompose.classList.remove('drag-over'); homeCompose.classList.remove('drag-over');
[...e.dataTransfer.files].filter(f => f.type.startsWith('image/')).forEach(homeProcessImage); [...e.dataTransfer.files].filter(f => f.type.startsWith('image/')).forEach(f => processImageFile(f, homeComposeBilder, homeRenderThumbs));
}); });
} }
@@ -890,69 +812,49 @@
const homePostCache = {}; const homePostCache = {};
let activeLbPostId = null;
let activeLbPostType = null;
function homeOpenPost(postId) { function homeOpenPost(postId) {
const p = homePostCache[postId]; const p = homePostCache[postId];
if (!p) return; if (!p) return;
activeLbPostId = p.postId;
activeLbPostType = p.postType || 'FEED';
const tempDiv = document.createElement('div'); const tempDiv = document.createElement('div');
tempDiv.innerHTML = renderHomePostCard(p); tempDiv.innerHTML = renderHomePostCard(p);
const card = tempDiv.firstElementChild; const card = tempDiv.firstElementChild;
if (card) { if (card) {
card.querySelectorAll('.post-actions').forEach(el => el.remove()); card.querySelectorAll('.post-actions').forEach(el => el.remove());
document.getElementById('lbPostBody').innerHTML = card.innerHTML; document.getElementById('lbPostBody').innerHTML = card.innerHTML;
_lbSetupContent(postId, 'hp', p.bilder);
} }
loadLbComments(p.postId, activeLbPostType); loadLbComments(p.postId, p.postType || 'FEED');
document.getElementById('postLightbox').classList.add('open'); document.getElementById('postLightbox').classList.add('open');
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';
} }
function closeLb() {
document.getElementById('postLightbox').classList.remove('open');
document.body.style.overflow = '';
activeLbPostId = null;
activeLbPostType = null;
}
document.getElementById('postLightbox').addEventListener('click', e => { document.getElementById('postLightbox').addEventListener('click', e => {
if (e.target === document.getElementById('postLightbox')) closeLb(); if (e.target === document.getElementById('postLightbox')) closeLb();
}); });
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && document.getElementById('postLightbox').classList.contains('open')) closeLb();
});
async function loadLbComments(postId, postType) { // ── Like / Delete ──────────────────────────────────────────────────────────
const targetType = postType === 'GROUP' ? 'GROUP_POST' : 'FEED_POST';
try { async function likeHomePost(postId, postType) {
const res = await fetch(`/social/kommentare?targetType=${targetType}&targetId=${postId}`); const ep = postType === 'GROUP'
const comments = await res.json(); ? `/gruppen/${document.getElementById('hpc-'+postId)?.dataset?.gruppeId}/posts/${postId}/like`
document.getElementById('lbCommentsList').innerHTML = comments.length === 0 : `/feed/posts/${postId}/like`;
? '<p style="color:var(--color-muted);font-size:0.82rem;margin:0.4rem;">Noch keine Kommentare.</p>' await fetch(ep, { method: 'POST' });
: comments.map(k => renderKommentarHtml(k, targetType, postId, { myUserId })).join(''); const btn = document.getElementById('hlk-' + postId);
} catch (_) {} const lc = document.getElementById('hlkc-' + postId);
const was = btn.classList.contains('active');
btn.classList.toggle('active', !was);
lc.textContent = parseInt(lc.textContent) + (was ? -1 : 1);
} }
async function postLbComment() { async function deleteHomePost(postId) {
if (!activeLbPostId) return; if (!confirm('Post löschen?')) return;
const input = document.getElementById('lbCommentInput'); const res = await fetch('/feed/posts/' + postId, { method: 'DELETE' });
const text = input.value.trim(); if (res.ok) document.getElementById('hpc-' + postId)?.remove();
if (!text) return;
const targetType = activeLbPostType === 'GROUP' ? 'GROUP_POST' : 'FEED_POST';
await fetch('/social/kommentare', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetType, targetId: activeLbPostId, text })
});
input.value = '';
await loadLbComments(activeLbPostId, activeLbPostType);
} }
async function deleteKommentar(kommentarId, targetType, targetId) { // ── Post-Karte ────────────────────────────────────────────────────────────
await fetch('/social/kommentare/' + kommentarId, { method: 'DELETE' });
await loadLbComments(targetId, activeLbPostType); const homeEditBilder = new Map();
}
function renderHomePostCard(p) { function renderHomePostCard(p) {
homePostCache[p.postId] = p; homePostCache[p.postId] = p;
@@ -963,7 +865,8 @@
const groupBadge = p.postType === 'GROUP' && p.gruppeId const groupBadge = p.postType === 'GROUP' && p.gruppeId
? `<span class="gruppe-badge">👥 ${esc(p.gruppeName)}</span>` ? `<span class="gruppe-badge">👥 ${esc(p.gruppeName)}</span>`
: ''; : '';
const bildHtml = bilderCarousel(p.bilder); const bildHtml = bilderGrid(p.bilder);
const editedLabel = p.editedAt ? ` <span style="font-size:0.75rem;color:var(--color-muted);">(bearbeitet)</span>` : '';
let umfrageHtml = ''; let umfrageHtml = '';
if (p.beitragTyp === 'UMFRAGE' && p.optionen && p.optionen.length > 0) { if (p.beitragTyp === 'UMFRAGE' && p.optionen && p.optionen.length > 0) {
const totalVotes = p.optionen.reduce((s, o) => s + o.stimmenCount, 0); const totalVotes = p.optionen.reduce((s, o) => s + o.stimmenCount, 0);
@@ -976,24 +879,74 @@
</div>`; </div>`;
}).join('') + `<div class="umfrage-total">${totalVotes} Stimme${totalVotes !== 1 ? 'n' : ''}</div></div>`; }).join('') + `<div class="umfrage-total">${totalVotes} Stimme${totalVotes !== 1 ? 'n' : ''}</div></div>`;
} }
return `<div class="post-card" onclick="homeOpenPost('${p.postId}')" style="cursor:pointer"> const canOwn = p.postType === 'FEED' && p.authorId === myUserId;
const ownBtns = canOwn
? `<div style="margin-left:auto;display:flex;gap:0.4rem;">
<button class="post-action-btn" onclick="event.stopPropagation();startHomeEdit('${p.postId}')" title="Bearbeiten" style="color:var(--color-muted)">✏</button>
<button class="post-action-btn post-delete" onclick="event.stopPropagation();deleteHomePost('${p.postId}')" title="Löschen">🗑</button>
</div>`
: '';
const gruppeIdAttr = p.gruppeId ? ` data-gruppe-id="${p.gruppeId}"` : '';
return `<div class="post-card" id="hpc-${p.postId}"${gruppeIdAttr} onclick="homeOpenPost('${p.postId}')">
<div class="post-header"> <div class="post-header">
<div class="post-avatar">${avatarHtml}</div> <div class="post-avatar">${avatarHtml}</div>
<div> <div>
<div class="post-author">${esc(p.authorName)}${privacyLabel}</div> <div class="post-author">${esc(p.authorName)}${privacyLabel}</div>
<div class="post-meta">${fmtDate(p.createdAt)}${groupBadge}</div> <div class="post-meta" id="hpm-${p.postId}">${fmtDate(p.createdAt)}${editedLabel}${groupBadge}</div>
</div> </div>
${ownBtns}
</div> </div>
<div id="hpva-${p.postId}">
<div class="post-text">${renderTextWithHashtags(p.text || '')}</div> <div class="post-text">${renderTextWithHashtags(p.text || '')}</div>
${bildHtml} <div id="hpbi-${p.postId}">${bildHtml}</div>
${umfrageHtml} </div>
<div id="hpea-${p.postId}" style="display:none;"></div>
<div id="hpum-${p.postId}">${umfrageHtml}</div>
<div class="post-actions"> <div class="post-actions">
<button class="post-action-btn${p.likedByMe ? ' active' : ''}">♥ <span>${p.likeCount}</span></button> <button class="post-action-btn${p.likedByMe ? ' active' : ''}" id="hlk-${p.postId}" onclick="event.stopPropagation();likeHomePost('${p.postId}','${p.postType}')">♥ <span id="hlkc-${p.postId}">${p.likeCount}</span></button>
<button class="post-action-btn">💬 <span>${p.kommentarCount}</span></button> <button class="post-action-btn" onclick="event.stopPropagation();homeOpenPost('${p.postId}')">💬 <span id="hkc-${p.postId}">${p.kommentarCount}</span></button>
</div> </div>
</div>`; </div>`;
} }
// ── Post-Bearbeitung (Home) ───────────────────────────────────────────────
function startHomeEdit(postId) {
const data = homePostCache[postId];
if (!data) return;
startPostEdit({ postId, prefix: 'hp', data, editBilderMap: homeEditBilder,
saveFn: 'saveHomeEdit', cancelFn: 'cancelHomeEdit',
addImgFn: 'homeEditAddImg', addOptionFn: 'homeEditAddOption', rmImgFn: 'homeEditRmImg' });
}
function cancelHomeEdit(postId) { cancelPostEdit(postId, 'hp', homeEditBilder); }
function homeEditRmImg(postId, idx) {
homeEditBilder.get(postId).splice(idx, 1);
_renderEditThumbs(homeEditBilder, postId, 'hp', 'homeEditRmImg');
}
function homeEditAddImg(input, postId) {
[...input.files].forEach(f => processImageFile(f, homeEditBilder.get(postId), () => _renderEditThumbs(homeEditBilder, postId, 'hp', 'homeEditRmImg')));
input.value = '';
}
function homeEditAddOption(postId) { editAddOptionRow(`hpeo-${postId}`); }
async function saveHomeEdit(postId) {
const cached = homePostCache[postId];
await savePostEdit({ postId, prefix: 'hp', endpoint: `/feed/posts/${postId}`,
isUmfrage: cached?.beitragTyp === 'UMFRAGE', editBilderMap: homeEditBilder,
onSuccess: updated => {
homePostCache[postId] = { ...cached, text: updated.text, bilder: updated.bilder || [], optionen: updated.optionen || [], multiChoice: updated.multiChoice };
const totalVotes = (updated.optionen || []).reduce((s, o) => s + o.stimmenCount, 0);
const umfrageHtml = updated.optionen?.length > 0
? '<div style="margin-top:0.5rem;">' + updated.optionen.map(o => {
const pct = totalVotes > 0 ? Math.round(o.stimmenCount / totalVotes * 100) : 0;
return `<div class="umfrage-option-bar"><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>`
: '';
applyPostEditDom(postId, 'hp', updated, umfrageHtml);
}
});
}
async function loadFeed() { async function loadFeed() {
try { try {
const res = await fetch('/feed/mine?size=3&page=0'); const res = await fetch('/feed/mine?size=3&page=0');