Aufgabenverwaltung angepasst, Eventseite weiter bearbeitet
Some checks failed
Host-Based Deploy (Java 21 Fix) / build-and-run (push) Has been cancelled

This commit is contained in:
2026-04-14 22:04:47 +02:00
parent e35b095c18
commit fdc0cfce95
43 changed files with 861 additions and 370 deletions

View File

@@ -120,7 +120,8 @@
.gruppe-info { font-size:0.75rem; color:var(--color-muted); margin-top:0.2rem; } .gruppe-info { font-size:0.75rem; color:var(--color-muted); margin-top:0.2rem; }
.gruppe-badges { display:flex; gap:0.3rem; margin-top:0.25rem; flex-wrap:wrap; } .gruppe-badges { display:flex; gap:0.3rem; margin-top:0.25rem; flex-wrap:wrap; }
.gruppe-badge { font-size:0.65rem; padding:0.1rem 0.4rem; border-radius:20px; background:rgba(255,255,255,0.07); color:var(--color-muted); } .gruppe-badge { font-size:0.65rem; padding:0.1rem 0.4rem; border-radius:20px; background:rgba(255,255,255,0.07); color:var(--color-muted); }
.gruppe-badge-public { background:rgba(46,204,113,0.15); color:var(--color-success); } .gruppe-badge-public { background:rgba(46,204,113,0.15); color:var(--color-success); }
.gruppe-badge-vanilla { background:#e8f5e9; color:#2e7d32; border:1px solid #a5d6a7; }
.gruppe-toggle { font-size:0.75rem; color:var(--color-muted); flex-shrink:0; transition:transform 0.2s; } .gruppe-toggle { font-size:0.75rem; color:var(--color-muted); flex-shrink:0; transition:transform 0.2s; }
.gruppe-card.open .gruppe-toggle { transform:rotate(90deg); } .gruppe-card.open .gruppe-toggle { transform:rotate(90deg); }
.gruppe-body { border-top:1px solid var(--color-secondary); padding:1rem 1rem 0.75rem; } .gruppe-body { border-top:1px solid var(--color-secondary); padding:1rem 1rem 0.75rem; }
@@ -277,6 +278,12 @@
<span style="font-size:0.78rem; color:var(--color-muted);">Aktuelles Bild neues wählen zum Ersetzen</span> <span style="font-size:0.78rem; color:var(--color-muted);">Aktuelles Bild neues wählen zum Ersetzen</span>
</div> </div>
<input type="file" id="gBild" accept="image/*"> <input type="file" id="gBild" accept="image/*">
<label style="margin-top:0.5rem;">
<span class="modal-check">
<input type="checkbox" id="gVanilla">
Auch für Vanilla-Game verfügbar
</span>
</label>
<div class="modal-error" id="gruppeModalError"></div> <div class="modal-error" id="gruppeModalError"></div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn-cancel" id="gruppeModalCancel">Abbrechen</button> <button class="btn-cancel" id="gruppeModalCancel">Abbrechen</button>
@@ -986,7 +993,7 @@ function renderAdminGruppen(gruppen) {
<div class="gruppe-meta"> <div class="gruppe-meta">
<div class="gruppe-name">${esc(g.name)}</div> <div class="gruppe-name">${esc(g.name)}</div>
<div class="gruppe-info">${g.von ? esc(g.von) + (counts ? ' · ' : '') : ''}${counts || 'Keine Einträge'}</div> <div class="gruppe-info">${g.von ? esc(g.von) + (counts ? ' · ' : '') : ''}${counts || 'Keine Einträge'}</div>
<div class="gruppe-badges"><span class="gruppe-badge gruppe-badge-public">Öffentlich</span></div> <div class="gruppe-badges"><span class="gruppe-badge gruppe-badge-public">Öffentlich</span>${g.vanillaAvailable ? '<span class="gruppe-badge gruppe-badge-vanilla">Vanilla</span>' : ''}</div>
</div> </div>
<span class="gruppe-toggle">▶</span> <span class="gruppe-toggle">▶</span>
</div> </div>
@@ -1262,6 +1269,7 @@ function openGruppeModal(editId) {
document.getElementById('gName').value = g.name || ''; document.getElementById('gName').value = g.name || '';
document.getElementById('gVon').value = g.von || ''; document.getElementById('gVon').value = g.von || '';
document.getElementById('gDesc').value = g.beschreibung || ''; document.getElementById('gDesc').value = g.beschreibung || '';
document.getElementById('gVanilla').checked = g.vanillaAvailable || false;
const imgWrap = document.getElementById('gCurrentImgWrap'); const imgWrap = document.getElementById('gCurrentImgWrap');
if (g.bild) { document.getElementById('gCurrentImg').src = 'data:image/png;base64,' + g.bild; imgWrap.style.display = 'flex'; } if (g.bild) { document.getElementById('gCurrentImg').src = 'data:image/png;base64,' + g.bild; imgWrap.style.display = 'flex'; }
else imgWrap.style.display = 'none'; else imgWrap.style.display = 'none';
@@ -1270,6 +1278,7 @@ function openGruppeModal(editId) {
document.getElementById('gName').value = ''; document.getElementById('gName').value = '';
document.getElementById('gVon').value = ''; document.getElementById('gVon').value = '';
document.getElementById('gDesc').value = ''; document.getElementById('gDesc').value = '';
document.getElementById('gVanilla').checked = false;
document.getElementById('gCurrentImgWrap').style.display = 'none'; document.getElementById('gCurrentImgWrap').style.display = 'none';
} }
gruppeModal.classList.add('open'); gruppeModal.classList.add('open');
@@ -1348,7 +1357,7 @@ gruppeModalSave.addEventListener('click', async () => {
let bildBase64 = null; let bildBase64 = null;
const fi = document.getElementById('gBild'); const fi = document.getElementById('gBild');
if (fi.files.length > 0) bildBase64 = await toBase64(fi.files[0]); if (fi.files.length > 0) bildBase64 = await toBase64(fi.files[0]);
const payload = { name, von: document.getElementById('gVon').value.trim() || null, beschreibung: document.getElementById('gDesc').value.trim() || null, bild: bildBase64 }; const payload = { name, von: document.getElementById('gVon').value.trim() || null, beschreibung: document.getElementById('gDesc').value.trim() || null, vanillaAvailable: document.getElementById('gVanilla').checked, bild: bildBase64 };
const isEdit = currentEditGruppeId != null; const isEdit = currentEditGruppeId != null;
fetch(isEdit ? `/admin/aufgabengruppen/${currentEditGruppeId}` : '/admin/aufgabengruppen', { fetch(isEdit ? `/admin/aufgabengruppen/${currentEditGruppeId}` : '/admin/aufgabengruppen', {
method: isEdit ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) method: isEdit ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload)

View File

@@ -195,6 +195,7 @@ function renderPage(evt) {
const totalAttendees = (evt.attendees || []).length; const totalAttendees = (evt.attendees || []).length;
const attending = evt.attendingMe; const attending = evt.attendingMe;
const isPast = new Date(evt.startAt) < new Date();
document.getElementById('content').innerHTML = ` document.getElementById('content').innerHTML = `
<div class="evt-header"> <div class="evt-header">
@@ -211,11 +212,13 @@ function renderPage(evt) {
<button class="btn" style="background:#c0392b;font-size:0.85rem;" onclick="openDeleteConfirm()">Löschen</button> <button class="btn" style="background:#c0392b;font-size:0.85rem;" onclick="openDeleteConfirm()">Löschen</button>
</div>` : ''} </div>` : ''}
<div style="display:flex;align-items:center;gap:0.4rem;flex-wrap:wrap;"> <div style="display:flex;align-items:center;gap:0.4rem;flex-wrap:wrap;">
<button class="btn" id="attendBtn" ${isPast
style="${attending ? 'background:var(--color-secondary);color:var(--color-text);' : ''}" ? `<span style="color:var(--color-muted);font-size:0.85rem;">Veranstaltung bereits beendet</span>`
onclick="toggleAttend()"> : `<button class="btn" id="attendBtn"
${attending ? '✓ Ich bin dabei' : '+ Ich bin dabei'} style="${attending ? 'background:var(--color-secondary);color:var(--color-text);' : ''}"
</button> onclick="toggleAttend()">
${attending ? '✓ Ich bin dabei' : '+ Ich bin dabei'}
</button>`}
<span style="color:var(--color-muted);font-size:0.85rem;" id="attendCount">${totalAttendees} Teilnehmer*in(nen)</span> <span style="color:var(--color-muted);font-size:0.85rem;" id="attendCount">${totalAttendees} Teilnehmer*in(nen)</span>
</div> </div>
</div> </div>

View File

@@ -445,6 +445,9 @@ function renderPage() {
${isOwner ? `<button class="btn" style="font-size:0.8rem;" onclick="openEventModal()">+ Veranstaltung erstellen</button>` : ''} ${isOwner ? `<button class="btn" style="font-size:0.8rem;" onclick="openEventModal()">+ Veranstaltung erstellen</button>` : ''}
</div> </div>
<div class="event-list" id="eventList"><p style="color:var(--color-muted);font-size:0.9rem;">Wird geladen…</p></div> <div class="event-list" id="eventList"><p style="color:var(--color-muted);font-size:0.9rem;">Wird geladen…</p></div>
<div id="allEventsLinkWrap" style="display:none;margin-top:0.75rem;">
<a id="allEventsLink" href="#" class="btn" style="display:inline-block;font-size:0.85rem;background:var(--color-secondary);color:var(--color-text);text-decoration:none;padding:0.45rem 1rem;border-radius:6px;">Alle Events anzeigen →</a>
</div>
<div id="pastEventsSection" style="display:none;"> <div id="pastEventsSection" style="display:none;">
<div class="section-title" style="margin-top:1.5rem;">Vergangene Veranstaltungen</div> <div class="section-title" style="margin-top:1.5rem;">Vergangene Veranstaltungen</div>
<div class="event-list" id="pastEventList"></div> <div class="event-list" id="pastEventList"></div>
@@ -517,8 +520,8 @@ function renderPage() {
${locHeaderHtml} ${locHeaderHtml}
${hoursHtml} ${hoursHtml}
${gallerySection} ${gallerySection}
${feedSection} ${eventsSection}
${eventsSection}`; ${feedSection}`;
} }
} }
@@ -587,15 +590,27 @@ async function loadEvents() {
if (!list) return; if (!list) return;
const now = new Date(); const now = new Date();
const future = events.filter(e => new Date(e.startAt) >= now); const future = events.filter(e => new Date(e.startAt) >= now)
.sort((a, b) => new Date(a.startAt) - new Date(b.startAt));
const past = events.filter(e => new Date(e.startAt) < now) const past = events.filter(e => new Date(e.startAt) < now)
.slice(-5) // letzte 5 .slice(-3) // letzte 3
.reverse(); // neueste zuerst .reverse(); // neueste zuerst
list.innerHTML = future.length const preview = future.slice(0, 3);
? future.map(e => buildEventCard(e, false)).join('') list.innerHTML = preview.length
? preview.map(e => buildEventCard(e, false)).join('')
: '<p style="color:var(--color-muted);font-size:0.9rem;">Keine bevorstehenden Veranstaltungen.</p>'; : '<p style="color:var(--color-muted);font-size:0.9rem;">Keine bevorstehenden Veranstaltungen.</p>';
const linkWrap = document.getElementById('allEventsLinkWrap');
if (linkWrap) {
if (future.length > 3) {
document.getElementById('allEventsLink').href = `/community/location-events.html?id=${locationId}`;
linkWrap.style.display = '';
} else {
linkWrap.style.display = 'none';
}
}
const pastSection = document.getElementById('pastEventsSection'); const pastSection = document.getElementById('pastEventsSection');
if (past.length && pastSection) { if (past.length && pastSection) {
document.getElementById('pastEventList').innerHTML = past.map(e => buildEventCard(e, true)).join(''); document.getElementById('pastEventList').innerHTML = past.map(e => buildEventCard(e, true)).join('');
@@ -1358,11 +1373,15 @@ function renderLocPost(p) {
<button class="post-action-btn post-delete" onclick="event.stopPropagation();deleteLocPost('${p.postId}')" title="Löschen">🗑</button> <button class="post-action-btn post-delete" onclick="event.stopPropagation();deleteLocPost('${p.postId}')" title="Löschen">🗑</button>
</div>` : ''; </div>` : '';
const authorUrl = p.posterType === 'LOCATION'
? `/community/location-detail.html?id=${p.locationId || p.authorId}`
: `/community/benutzer.html?userId=${p.authorId}`;
return `<div class="post-card${clickableClass}" id="lp-${p.postId}"${onClickAttr}> return `<div class="post-card${clickableClass}" id="lp-${p.postId}"${onClickAttr}>
<div class="post-header"> <div class="post-header">
<div class="post-avatar">${avHtml}</div> <div class="post-avatar"><a href="${authorUrl}" onclick="event.stopPropagation()" style="display:contents;">${avHtml}</a></div>
<div> <div>
<div class="post-author">${escHtml(p.authorName || p.locationName || '')}</div> <div class="post-author"><a href="${authorUrl}" style="color:inherit;text-decoration:none;" onclick="event.stopPropagation()">${escHtml(p.authorName || p.locationName || '')}</a></div>
<div class="post-meta">${dateStr}${editedHtml}</div> <div class="post-meta">${dateStr}${editedHtml}</div>
</div> </div>
${adminBtns} ${adminBtns}
@@ -1401,11 +1420,15 @@ function openLpLb(postId) {
+ ' ' + new Date(p.createdAt).toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' }); + ' ' + 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 editedHtml = p.editedAt ? ' <span style="font-size:0.7rem;color:var(--color-muted);">(bearbeitet)</span>' : '';
const lbAuthorUrl = p.posterType === 'LOCATION'
? `/community/location-detail.html?id=${p.locationId || p.authorId}`
: `/community/benutzer.html?userId=${p.authorId}`;
document.getElementById('lbPostBody').innerHTML = ` document.getElementById('lbPostBody').innerHTML = `
<div class="post-header"> <div class="post-header">
<div class="post-avatar">${avHtml}</div> <div class="post-avatar"><a href="${lbAuthorUrl}" style="display:contents;">${avHtml}</a></div>
<div> <div>
<div class="post-author">${escHtml(p.authorName || p.locationName || '')}</div> <div class="post-author"><a href="${lbAuthorUrl}" style="color:inherit;text-decoration:none;">${escHtml(p.authorName || p.locationName || '')}</a></div>
<div class="post-meta">${dateStr}${editedHtml}</div> <div class="post-meta">${dateStr}${editedHtml}</div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,164 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Alle Events xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="/css/community.css">
<style>
.back-link { display:inline-flex; align-items:center; gap:0.35rem; color:var(--color-muted); font-size:0.88rem; text-decoration:none; margin-bottom:1rem; }
.back-link:hover { color:var(--color-primary); }
.page-title { font-size:1.15rem; font-weight:700; margin:0 0 1.25rem; }
.event-list { display:flex; flex-direction:column; gap:0.75rem; }
.event-card { background:var(--color-card); border:1px solid var(--color-secondary); border-radius:10px; display:flex; gap:0.75rem; padding:0.75rem; text-decoration:none; color:inherit; transition:border-color 0.15s; cursor:pointer; }
.event-card:hover { border-color:var(--color-primary); }
.event-card-img { width:64px; height:64px; border-radius:8px; object-fit:cover; background:var(--color-secondary); flex-shrink:0; overflow:hidden; display:flex; align-items:center; justify-content:center; font-size:1.4rem; }
.event-card-img img { width:100%; height:100%; object-fit:cover; }
.event-card-body { flex:1; min-width:0; }
.event-card-title { font-weight:600; font-size:0.92rem; margin:0 0 0.2rem; }
.event-card-date { font-size:0.78rem; color:var(--color-muted); }
.event-card-attendees { font-size:0.78rem; color:var(--color-muted); }
.paging-bar { display:flex; align-items:center; justify-content:center; gap:0.75rem; margin-top:1.25rem; flex-wrap:wrap; }
.paging-bar button { background:var(--color-secondary); border:none; color:var(--color-text); padding:0.45rem 1rem; border-radius:6px; font-size:0.88rem; cursor:pointer; transition:background 0.15s; }
.paging-bar button:hover:not(:disabled) { background:var(--color-primary); color:#fff; }
.paging-bar button:disabled { opacity:0.4; cursor:default; }
.paging-info { font-size:0.85rem; color:var(--color-muted); }
.empty-hint { color:var(--color-muted); font-size:0.9rem; }
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<a class="back-link" id="backLink" href="#"> Zurück zur Location</a>
<div id="locName" class="page-title">Alle Events</div>
<div id="eventList" class="event-list">
<p class="empty-hint">Wird geladen…</p>
</div>
<div class="paging-bar" id="pagingBar" style="display:none;">
<button id="prevBtn" onclick="changePage(-1)" disabled> Zurück</button>
<span class="paging-info" id="pagingInfo"></span>
<button id="nextBtn" onclick="changePage(1)">Weiter </button>
</div>
</div>
</div>
<script src="/js/nav.js"></script>
<script>
const PAGE_SIZE = 10;
const params = new URLSearchParams(location.search);
const locationId = params.get('id');
let allEvents = [];
let currentPage = 0;
document.getElementById('backLink').href = `/community/location-detail.html?id=${locationId}`;
function escHtml(s) {
if (!s) return '';
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function formatDate(iso) {
if (!iso) return '';
const d = new Date(iso);
return d.toLocaleDateString('de-DE', { weekday:'short', day:'2-digit', month:'2-digit', year:'numeric' })
+ ' ' + d.toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' }) + ' Uhr';
}
function buildEventCard(e) {
const imgHtml = e.imageData
? `<img src="data:image/jpeg;base64,${e.imageData}" alt="${escHtml(e.title)}">`
: '🗓';
return `
<a class="event-card" href="/community/event-detail.html?id=${e.eventId}">
<div class="event-card-img">${imgHtml}</div>
<div class="event-card-body">
<div class="event-card-title">${escHtml(e.title)}</div>
<div class="event-card-date">${formatDate(e.startAt)}</div>
<div class="event-card-attendees">${e.attendeeCount} Teilnehmer*in(nen)${e.attendingMe ? ' · Du nimmst teil' : ''}</div>
</div>
</a>`;
}
function renderPage() {
const list = document.getElementById('eventList');
const totalPages = Math.ceil(allEvents.length / PAGE_SIZE);
const start = currentPage * PAGE_SIZE;
const slice = allEvents.slice(start, start + PAGE_SIZE);
list.innerHTML = slice.length
? slice.map(buildEventCard).join('')
: '<p class="empty-hint">Keine weiteren Veranstaltungen.</p>';
const pagingBar = document.getElementById('pagingBar');
if (allEvents.length > PAGE_SIZE) {
pagingBar.style.display = '';
document.getElementById('prevBtn').disabled = currentPage === 0;
document.getElementById('nextBtn').disabled = currentPage >= totalPages - 1;
document.getElementById('pagingInfo').textContent =
`Seite ${currentPage + 1} von ${totalPages} (${allEvents.length} Events)`;
} else {
pagingBar.style.display = 'none';
}
}
function changePage(dir) {
const totalPages = Math.ceil(allEvents.length / PAGE_SIZE);
const next = currentPage + dir;
if (next < 0 || next >= totalPages) return;
currentPage = next;
renderPage();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
async function init() {
if (!locationId) {
document.getElementById('eventList').innerHTML = '<p class="empty-hint">Keine Location-ID angegeben.</p>';
return;
}
// Locationname laden
const locRes = await fetch(`/locations/${locationId}`);
if (locRes.ok) {
const loc = await locRes.json();
document.getElementById('locName').textContent = `Alle Events ${loc.name}`;
document.title = `Events ${loc.name} xXx Sphere`;
}
// Events laden
const res = await fetch(`/locations/${locationId}/events`);
if (!res.ok) {
document.getElementById('eventList').innerHTML = '<p class="empty-hint">Events konnten nicht geladen werden.</p>';
return;
}
const events = await res.json();
const now = new Date();
allEvents = events
.filter(e => new Date(e.startAt) >= now)
.sort((a, b) => new Date(a.startAt) - new Date(b.startAt));
if (allEvents.length === 0) {
document.getElementById('eventList').innerHTML = '<p class="empty-hint">Keine bevorstehenden Veranstaltungen.</p>';
return;
}
renderPage();
}
init();
</script>
</body>
</html>

View File

@@ -95,6 +95,7 @@
} }
.gruppe-badge-private { background: rgba(233,69,96,0.15); color: var(--color-primary); } .gruppe-badge-private { background: rgba(233,69,96,0.15); color: var(--color-primary); }
.gruppe-badge-public { background: rgba(46,204,113,0.15); color: var(--color-success); } .gruppe-badge-public { background: rgba(46,204,113,0.15); color: var(--color-success); }
.gruppe-badge-vanilla { background: #e8f5e9; color: #2e7d32; border: 1px solid #a5d6a7; }
.gruppe-toggle { font-size: 0.75rem; color: var(--color-muted); flex-shrink: 0; transition: transform 0.2s; } .gruppe-toggle { font-size: 0.75rem; color: var(--color-muted); flex-shrink: 0; transition: transform 0.2s; }
.gruppe-card.open .gruppe-toggle { transform: rotate(90deg); } .gruppe-card.open .gruppe-toggle { transform: rotate(90deg); }
@@ -367,6 +368,12 @@
Gruppe veröffentlichen (für alle sichtbar) Gruppe veröffentlichen (für alle sichtbar)
</span> </span>
</label> </label>
<label>
<span class="modal-check">
<input type="checkbox" id="gVanilla">
Auch für Vanilla-Game verfügbar
</span>
</label>
<div class="modal-error" id="modalError"></div> <div class="modal-error" id="modalError"></div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn-cancel" id="cancelBtn">Abbrechen</button> <button class="btn-cancel" id="cancelBtn">Abbrechen</button>
@@ -715,6 +722,7 @@
if (g.privateGruppe) badges.push(`<span class="gruppe-badge gruppe-badge-private">Privat</span>`); if (g.privateGruppe) badges.push(`<span class="gruppe-badge gruppe-badge-private">Privat</span>`);
else badges.push(`<span class="gruppe-badge gruppe-badge-public">Öffentlich</span>`); else badges.push(`<span class="gruppe-badge gruppe-badge-public">Öffentlich</span>`);
if (type === 'user' && g.subscriberCount > 0) badges.push(`<span class="gruppe-badge">♥ ${g.subscriberCount} Abo${g.subscriberCount !== 1 ? 's' : ''}</span>`); if (type === 'user' && g.subscriberCount > 0) badges.push(`<span class="gruppe-badge">♥ ${g.subscriberCount} Abo${g.subscriberCount !== 1 ? 's' : ''}</span>`);
if (g.vanillaAvailable) badges.push(`<span class="gruppe-badge gruppe-badge-vanilla">Vanilla</span>`);
return ` return `
<div class="gruppe-card" id="gruppe-${esc(g.gruppenId)}"> <div class="gruppe-card" id="gruppe-${esc(g.gruppenId)}">
@@ -1069,6 +1077,7 @@
pubCb.checked = !g.privateGruppe; pubCb.checked = !g.privateGruppe;
pubCb.disabled = g.privateGruppe; // Veröffentlichen nur über den Veröffentlichen-Button pubCb.disabled = g.privateGruppe; // Veröffentlichen nur über den Veröffentlichen-Button
document.getElementById('gPublicLabel').style.display = 'block'; document.getElementById('gPublicLabel').style.display = 'block';
document.getElementById('gVanilla').checked = g.vanillaAvailable || false;
const imgWrap = document.getElementById('gCurrentImgWrap'); const imgWrap = document.getElementById('gCurrentImgWrap');
if (g.bild) { if (g.bild) {
document.getElementById('gCurrentImg').src = 'data:image/png;base64,' + g.bild; document.getElementById('gCurrentImg').src = 'data:image/png;base64,' + g.bild;
@@ -1086,6 +1095,7 @@
document.getElementById('gDesc').value = ''; document.getElementById('gDesc').value = '';
document.getElementById('gPublic').checked = false; document.getElementById('gPublic').checked = false;
document.getElementById('gPublicLabel').style.display = 'none'; document.getElementById('gPublicLabel').style.display = 'none';
document.getElementById('gVanilla').checked = false;
document.getElementById('gCurrentImgWrap').style.display = 'none'; document.getElementById('gCurrentImgWrap').style.display = 'none';
gruppeModal.classList.add('open'); gruppeModal.classList.add('open');
document.getElementById('gName').focus(); document.getElementById('gName').focus();
@@ -1119,6 +1129,7 @@
name, name,
beschreibung: document.getElementById('gDesc').value.trim() || null, beschreibung: document.getElementById('gDesc').value.trim() || null,
privateGruppe: isEdit ? !document.getElementById('gPublic').checked : true, privateGruppe: isEdit ? !document.getElementById('gPublic').checked : true,
vanillaAvailable: document.getElementById('gVanilla').checked,
bild: bildBase64 bild: bildBase64
}; };

View File

@@ -97,6 +97,7 @@
.item-img { width: 38px; height: 38px; object-fit: cover; border-radius: 6px; flex-shrink: 0; } .item-img { width: 38px; height: 38px; object-fit: cover; border-radius: 6px; flex-shrink: 0; }
.gruppe-item-name, .toy-item-name { font-size: 0.95rem; font-weight: 600; color: var(--color-text); } .gruppe-item-name, .toy-item-name { font-size: 0.95rem; font-weight: 600; color: var(--color-text); }
.gruppe-item-desc, .toy-item-desc { display: block; font-size: 0.8rem; color: var(--color-muted); margin-top: 0.15rem; } .gruppe-item-desc, .toy-item-desc { display: block; font-size: 0.8rem; color: var(--color-muted); margin-top: 0.15rem; }
.vanilla-badge { display: inline-block; font-size: 0.65rem; font-weight: 700; padding: 0.1rem 0.35rem; border-radius: 3px; background: #e8f5e9; color: #2e7d32; border: 1px solid #a5d6a7; margin-left: 0.35rem; vertical-align: middle; letter-spacing: 0.03em; }
.empty-hint { color: var(--color-muted); font-size: 0.875rem; font-style: italic; padding: 0.5rem 0; } .empty-hint { color: var(--color-muted); font-size: 0.875rem; font-style: italic; padding: 0.5rem 0; }
.aufgaben-section-label { font-size: 0.78rem; font-weight: 600; color: var(--color-muted); text-transform: uppercase; letter-spacing: 0.04em; margin: 1rem 0 0.5rem 0; } .aufgaben-section-label { font-size: 0.78rem; font-weight: 600; color: var(--color-muted); text-transform: uppercase; letter-spacing: 0.04em; margin: 1rem 0 0.5rem 0; }
.aufgaben-section-label:first-child { margin-top: 0; } .aufgaben-section-label:first-child { margin-top: 0; }
@@ -653,9 +654,10 @@
} }
ul.innerHTML = gruppen.map(g => { ul.innerHTML = gruppen.map(g => {
const checked = savedGruppen.has(g.gruppenId); const checked = savedGruppen.has(g.gruppenId);
const vanillaBadge = g.vanillaAvailable ? '<span class="vanilla-badge">Vanilla</span>' : '';
return `<li><label class="gruppe-item${checked ? ' is-checked' : ''}"> return `<li><label class="gruppe-item${checked ? ' is-checked' : ''}">
<input type="checkbox" value="${g.gruppenId}"${checked ? ' checked' : ''}> <input type="checkbox" value="${g.gruppenId}"${checked ? ' checked' : ''}>
<span><span class="gruppe-item-name">${g.name}</span>${g.beschreibung ? `<span class="gruppe-item-desc">${g.beschreibung}</span>` : ''}</span> <span><span class="gruppe-item-name">${g.name}${vanillaBadge}</span>${g.beschreibung ? `<span class="gruppe-item-desc">${g.beschreibung}</span>` : ''}</span>
${g.bild ? `<img class="item-img" src="data:image/png;base64,${g.bild}" alt="">` : ''} ${g.bild ? `<img class="item-img" src="data:image/png;base64,${g.bild}" alt="">` : ''}
</label></li>`; </label></li>`;
}).join(''); }).join('');

View File

@@ -887,11 +887,14 @@
</div>` </div>`
: ''; : '';
const gruppeIdAttr = p.gruppeId ? ` data-gruppe-id="${p.gruppeId}"` : ''; const gruppeIdAttr = p.gruppeId ? ` data-gruppe-id="${p.gruppeId}"` : '';
const authorUrl = p.posterType === 'LOCATION'
? `/community/location-detail.html?id=${p.locationId || p.authorId}`
: `/community/benutzer.html?userId=${p.authorId}`;
return `<div class="post-card" id="hpc-${p.postId}"${gruppeIdAttr} onclick="homeOpenPost('${p.postId}')"> 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"><a href="${authorUrl}" 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="${authorUrl}" style="color:inherit;text-decoration:none;" onclick="event.stopPropagation()">${esc(p.authorName)}</a>${privacyLabel}</div>
<div class="post-meta" id="hpm-${p.postId}">${fmtDate(p.createdAt)}${editedLabel}${groupBadge}</div> <div class="post-meta" id="hpm-${p.postId}">${fmtDate(p.createdAt)}${editedLabel}${groupBadge}</div>
</div> </div>
${ownBtns} ${ownBtns}

View File

@@ -203,8 +203,10 @@ public class AdminController {
@GetMapping("/aufgabengruppen") @GetMapping("/aufgabengruppen")
public ResponseEntity<List<AufgabenGruppe>> getAufgabengruppen(Principal principal) { public ResponseEntity<List<AufgabenGruppe>> getAufgabengruppen(Principal principal) {
requireAdmin(principal); requireAdmin(principal);
List<AufgabenGruppeEntity> list = aufgabenGruppeRepository List<AufgabenGruppeEntity> list = aufgabenGruppeRepository.findAll().stream()
.findByUserIdIsNull(PageRequest.of(0, 1000)).getContent(); .filter(g -> g.getUserId() == null)
.sorted(java.util.Comparator.comparing(AufgabenGruppeEntity::getName, String.CASE_INSENSITIVE_ORDER))
.toList();
return ResponseEntity.ok(list.stream().map(AufgabenGruppeEntity::toAufgabenGruppe).toList()); return ResponseEntity.ok(list.stream().map(AufgabenGruppeEntity::toAufgabenGruppe).toList());
} }
@@ -230,9 +232,11 @@ public class AdminController {
entity.setName(gruppe.getName()); entity.setName(gruppe.getName());
entity.setBeschreibung(gruppe.getBeschreibung()); entity.setBeschreibung(gruppe.getBeschreibung());
entity.setVon(gruppe.getVon()); entity.setVon(gruppe.getVon());
entity.setVanillaAvailable(gruppe.isVanillaAvailable());
if (gruppe.getBild() != null) { if (gruppe.getBild() != null) {
entity.setBild(java.util.Base64.getDecoder().decode(gruppe.getBild())); entity.setBild(java.util.Base64.getDecoder().decode(gruppe.getBild()));
} }
aufgabenGruppeRepository.save(entity);
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }

View File

@@ -28,6 +28,16 @@ public class SecurityConfig {
http http
.csrf(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable)
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.headers(headers -> headers
.frameOptions(frame -> frame.deny())
.contentTypeOptions(ct -> {})
.httpStrictTransportSecurity(hsts -> hsts.includeSubDomains(true).maxAgeInSeconds(31536000))
.addHeaderWriter((request, response) -> {
response.setHeader("X-XSS-Protection", "1; mode=block");
response.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
response.setHeader("Permissions-Policy", "geolocation=(), microphone=(), camera=()");
})
)
.exceptionHandling(ex -> ex .exceptionHandling(ex -> ex
.authenticationEntryPoint((request, response, authException) -> .authenticationEntryPoint((request, response, authException) ->
response.sendRedirect("/login.html"))) response.sendRedirect("/login.html")))
@@ -77,6 +87,7 @@ public class SecurityConfig {
.requestMatchers("/dating/matches.html").authenticated() .requestMatchers("/dating/matches.html").authenticated()
.requestMatchers("/community/locations.html").authenticated() .requestMatchers("/community/locations.html").authenticated()
.requestMatchers("/community/location-detail.html").authenticated() .requestMatchers("/community/location-detail.html").authenticated()
.requestMatchers("/community/location-events.html").authenticated()
.requestMatchers("/community/events.html").authenticated() .requestMatchers("/community/events.html").authenticated()
.requestMatchers("/community/event-detail.html").authenticated() .requestMatchers("/community/event-detail.html").authenticated()
.requestMatchers("/gruppen/**").authenticated() .requestMatchers("/gruppen/**").authenticated()

View File

@@ -4,12 +4,17 @@ import de.oaa.xxx.mail.Email;
import de.oaa.xxx.mail.MailService; import de.oaa.xxx.mail.MailService;
import de.oaa.xxx.support.SupportUserService; import de.oaa.xxx.support.SupportUserService;
import de.oaa.xxx.user.UserRepository; import de.oaa.xxx.user.UserRepository;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.security.Principal; import java.security.Principal;
import java.time.Instant;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Map;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@RestController @RestController
@RequestMapping("/api/feedback") @RequestMapping("/api/feedback")
@@ -20,6 +25,10 @@ public class FeedbackController {
private final UserRepository userRepository; private final UserRepository userRepository;
private final SupportUserService supportUserService; private final SupportUserService supportUserService;
/** Rate-Limiting: Key → letzter Aufruf (Epoch-Sekunden) */
private final Map<String, Long> lastCallAt = new ConcurrentHashMap<>();
private static final long RATE_LIMIT_SECONDS = 60;
public FeedbackController(MailService mailService, public FeedbackController(MailService mailService,
FeedbackRepository feedbackRepository, FeedbackRepository feedbackRepository,
UserRepository userRepository, UserRepository userRepository,
@@ -33,11 +42,29 @@ public class FeedbackController {
record FeedbackRequest(String name, String seite, String grund, String text) {} record FeedbackRequest(String name, String seite, String grund, String text) {}
@PostMapping @PostMapping
public ResponseEntity<Void> send(@RequestBody FeedbackRequest req, Principal principal) { public ResponseEntity<Void> send(@RequestBody FeedbackRequest req,
Principal principal,
HttpServletRequest httpRequest) {
if (req.text() == null || req.text().isBlank() || req.text().length() < 10 || req.text().length() > 1000) { if (req.text() == null || req.text().isBlank() || req.text().length() < 10 || req.text().length() > 1000) {
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build();
} }
// E-Mail-Header-Injection verhindern: Felder dürfen keine Zeilenumbrüche enthalten
if (containsLineBreak(req.name()) || containsLineBreak(req.seite()) || containsLineBreak(req.grund())) {
return ResponseEntity.badRequest().build();
}
// Rate-Limiting: 1 Aufruf pro Minute pro eingeloggtem User oder IP
String rateLimitKey = principal != null ? "user:" + principal.getName()
: "ip:" + httpRequest.getRemoteAddr();
long now = Instant.now().getEpochSecond();
Long last = lastCallAt.get(rateLimitKey);
if (last != null && (now - last) < RATE_LIMIT_SECONDS) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).build();
}
lastCallAt.put(rateLimitKey, now);
// Eingeloggten User ermitteln (optional) // Eingeloggten User ermitteln (optional)
UUID userId = null; UUID userId = null;
if (principal != null) { if (principal != null) {
@@ -86,4 +113,8 @@ public class FeedbackController {
if (s == null) return ""; if (s == null) return "";
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;"); return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
} }
private boolean containsLineBreak(String s) {
return s != null && (s.contains("\n") || s.contains("\r"));
}
} }

View File

@@ -79,7 +79,8 @@ public class AboController {
String namePattern = name != null && !name.isBlank() ? "%" + name.trim() + "%" : null; String namePattern = name != null && !name.isBlank() ? "%" + name.trim() + "%" : null;
List<AufgabenGruppe> dtos = gruppeRepository List<AufgabenGruppe> dtos = gruppeRepository
.findPublicFromOthers(user.getUserId(), namePattern).stream() .listAllWithUserAndSearch(user.getUserId(), namePattern, org.springframework.data.domain.PageRequest.of(0, 500)).stream()
.filter(g -> !g.isPrivateGruppe() && g.getUserId() != null && !g.getUserId().equals(user.getUserId()))
.map(g -> enrich(g, user.getUserId(), aboRepository.existsByUserIdAndAufgabenGruppe(user.getUserId(), g))) .map(g -> enrich(g, user.getUserId(), aboRepository.existsByUserIdAndAufgabenGruppe(user.getUserId(), g)))
.sorted(Comparator.comparingLong(AufgabenGruppe::getSubscriberCount).reversed() .sorted(Comparator.comparingLong(AufgabenGruppe::getSubscriberCount).reversed()
.thenComparing(AufgabenGruppe::getName, String.CASE_INSENSITIVE_ORDER)) .thenComparing(AufgabenGruppe::getName, String.CASE_INSENSITIVE_ORDER))

View File

@@ -1,257 +1,259 @@
package de.oaa.xxx.games.bdsm.controller; package de.oaa.xxx.games.bdsm.controller;
import java.security.Principal; import de.oaa.xxx.games.common.aufgaben.AufgabenGruppe;
import java.util.Base64; import de.oaa.xxx.games.common.aufgaben.AufgabenGruppeList;
import java.util.UUID; import de.oaa.xxx.games.common.aufgaben.AufgabenGruppePage;
import de.oaa.xxx.games.common.aufgaben.AufgabenGruppeService;
import org.slf4j.Logger; import de.oaa.xxx.games.common.entity.AufgabenGruppeEntity;
import org.slf4j.LoggerFactory; import de.oaa.xxx.games.common.repository.AufgabeRepository;
import org.springframework.data.domain.Page; import de.oaa.xxx.games.common.repository.AufgabenGruppeRepository;
import org.springframework.data.domain.PageRequest; import de.oaa.xxx.games.common.repository.FinisherRepository;
import org.springframework.data.domain.Sort; import de.oaa.xxx.games.common.repository.GruppenAboRepository;
import org.springframework.http.ResponseEntity; import de.oaa.xxx.games.common.repository.SperreRepository;
import org.springframework.transaction.annotation.Transactional; import de.oaa.xxx.games.common.repository.StrafeRepository;
import org.springframework.web.bind.annotation.DeleteMapping; import de.oaa.xxx.subscription.SubscriptionLimitService;
import org.springframework.web.bind.annotation.GetMapping; import de.oaa.xxx.user.UserEntity;
import org.springframework.web.bind.annotation.PathVariable; import de.oaa.xxx.user.UserService;
import org.springframework.web.bind.annotation.PostMapping; import org.slf4j.Logger;
import org.springframework.web.bind.annotation.PutMapping; import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.data.domain.Page;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.data.domain.PageRequest;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.data.domain.Sort;
import org.springframework.web.bind.annotation.RestController; import org.springframework.http.ResponseEntity;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping;
import de.oaa.xxx.games.common.aufgaben.AufgabenGruppe; import org.springframework.web.bind.annotation.GetMapping;
import de.oaa.xxx.games.common.aufgaben.AufgabenGruppeList; import org.springframework.web.bind.annotation.PathVariable;
import de.oaa.xxx.games.common.aufgaben.AufgabenGruppePage; import org.springframework.web.bind.annotation.PostMapping;
import de.oaa.xxx.games.common.aufgaben.AufgabenGruppeService; import org.springframework.web.bind.annotation.PutMapping;
import de.oaa.xxx.games.common.entity.AufgabenGruppeEntity; import org.springframework.web.bind.annotation.RequestBody;
import de.oaa.xxx.games.common.repository.AufgabeRepository; import org.springframework.web.bind.annotation.RequestMapping;
import de.oaa.xxx.games.common.repository.AufgabenGruppeRepository; import org.springframework.web.bind.annotation.RequestParam;
import de.oaa.xxx.games.common.repository.FinisherRepository; import org.springframework.web.bind.annotation.RestController;
import de.oaa.xxx.games.common.repository.GruppenAboRepository; import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import de.oaa.xxx.games.common.repository.SperreRepository;
import de.oaa.xxx.games.common.repository.StrafeRepository; import java.security.Principal;
import de.oaa.xxx.subscription.SubscriptionLimitService; import java.util.Base64;
import de.oaa.xxx.user.UserEntity; import java.util.UUID;
import de.oaa.xxx.user.UserService;
@RestController
@RestController @RequestMapping("/gruppe")
@RequestMapping("/gruppe") @Transactional
@Transactional public class AufgabenGruppeController {
public class AufgabenGruppeController {
private static final Logger LOGGER = LoggerFactory.getLogger(AufgabenGruppeController.class);
private static final Logger LOGGER = LoggerFactory.getLogger(AufgabenGruppeController.class); private static final int DEFAULT_PAGE_SIZE = 5;
private static final int DEFAULT_PAGE_SIZE = 5;
private final AufgabenGruppeRepository gruppeRepository;
private final AufgabenGruppeRepository gruppeRepository; private final AufgabeRepository aufgabeRepository;
private final AufgabeRepository aufgabeRepository; private final StrafeRepository strafeRepository;
private final StrafeRepository strafeRepository; private final SperreRepository sperreRepository;
private final SperreRepository sperreRepository; private final FinisherRepository finisherRepository;
private final FinisherRepository finisherRepository; private final GruppenAboRepository aboRepository;
private final GruppenAboRepository aboRepository; private final AufgabenGruppeService aufgabenGruppeService;
private final AufgabenGruppeService aufgabenGruppeService; private final SubscriptionLimitService limitService;
private final SubscriptionLimitService limitService; private final UserService userService;
private final UserService userService;
public AufgabenGruppeController(AufgabenGruppeRepository gruppeRepository,
public AufgabenGruppeController(AufgabenGruppeRepository gruppeRepository, AufgabeRepository aufgabeRepository,
AufgabeRepository aufgabeRepository, StrafeRepository strafeRepository,
StrafeRepository strafeRepository, SperreRepository sperreRepository,
SperreRepository sperreRepository, FinisherRepository finisherRepository,
FinisherRepository finisherRepository, GruppenAboRepository aboRepository,
GruppenAboRepository aboRepository, AufgabenGruppeService aufgabenGruppeService,
AufgabenGruppeService aufgabenGruppeService, SubscriptionLimitService limitService,
SubscriptionLimitService limitService, UserService userService) {
UserService userService) { this.gruppeRepository = gruppeRepository;
this.gruppeRepository = gruppeRepository; this.aufgabeRepository = aufgabeRepository;
this.aufgabeRepository = aufgabeRepository; this.strafeRepository = strafeRepository;
this.strafeRepository = strafeRepository; this.sperreRepository = sperreRepository;
this.sperreRepository = sperreRepository; this.finisherRepository = finisherRepository;
this.finisherRepository = finisherRepository; this.aboRepository = aboRepository;
this.aboRepository = aboRepository; this.aufgabenGruppeService = aufgabenGruppeService;
this.aufgabenGruppeService = aufgabenGruppeService; this.limitService = limitService;
this.limitService = limitService; this.userService = userService;
this.userService = userService; }
}
// ── Paginierte Listen (alle Gruppen BDSM-Verwaltung und Spielstart) ──
// ── Paginierte Listen ──
@GetMapping("/list/user")
@GetMapping("/list/user") public ResponseEntity<AufgabenGruppePage> listUser(
public ResponseEntity<AufgabenGruppePage> listUser( @RequestParam(name = "page", defaultValue = "0") int page,
@RequestParam(name = "page", defaultValue = "0") int page, @RequestParam(name = "size", defaultValue = "" + DEFAULT_PAGE_SIZE) int size,
@RequestParam(name = "size", defaultValue = "" + DEFAULT_PAGE_SIZE) int size, Principal principal) {
Principal principal) { UserEntity user = resolveUser(principal);
UserEntity user = resolveUser(principal); if (user == null) return ResponseEntity.status(401).build();
if (user == null) return ResponseEntity.status(401).build(); Page<AufgabenGruppeEntity> result = gruppeRepository.findByUserId(
Page<AufgabenGruppeEntity> result = gruppeRepository.findByUserId( user.getUserId(), PageRequest.of(page, size, Sort.by("name")));
user.getUserId(), PageRequest.of(page, size, Sort.by("name"))); return ResponseEntity.ok(toGruppePage(result, true));
return ResponseEntity.ok(toGruppePage(result, true)); }
}
@GetMapping("/list/system")
@GetMapping("/list/system") public ResponseEntity<AufgabenGruppePage> listSystem(
public ResponseEntity<AufgabenGruppePage> listSystem( @RequestParam(name = "page", defaultValue = "0") int page,
@RequestParam(name = "page", defaultValue = "0") int page, @RequestParam(name = "size", defaultValue = "" + DEFAULT_PAGE_SIZE) int size) {
@RequestParam(name = "size", defaultValue = "" + DEFAULT_PAGE_SIZE) int size) { Page<AufgabenGruppeEntity> result = gruppeRepository.findByUserIdIsNull(
Page<AufgabenGruppeEntity> result = gruppeRepository.findByUserIdIsNull( PageRequest.of(page, size, Sort.by("name")));
PageRequest.of(page, size, Sort.by("name"))); return ResponseEntity.ok(toGruppePage(result));
return ResponseEntity.ok(toGruppePage(result)); }
}
// ── Bestehende Endpunkte ──
// ── Bestehende Endpunkte ──
@GetMapping("/all")
@GetMapping("/all") public ResponseEntity<AufgabenGruppeList> getAll(@RequestParam(required = false) String search, Principal principal) {
public ResponseEntity<AufgabenGruppeList> getAll(@RequestParam(required = false) String search, Principal principal) { UUID userId = userService.requireUser(principal).getUserId();
UUID userId = userService.requireUser(principal).getUserId(); String searchPattern = search != null ? "%" + search + "%" : null;
String searchPattern = search != null ? "%" + search + "%" : null; AufgabenGruppeList list = new AufgabenGruppeList();
AufgabenGruppeList list = new AufgabenGruppeList(); list.setGruppen(gruppeRepository.listAllWithUserAndSearch(userId, searchPattern, PageRequest.of(0, 500))
list.setGruppen(gruppeRepository.listWithUserAndSearch(userId, searchPattern, PageRequest.of(0, 500)) .stream().map(AufgabenGruppeEntity::toAufgabenGruppeDisplay).toList());
.stream().map(AufgabenGruppeEntity::toAufgabenGruppeDisplay).toList()); return ResponseEntity.ok(list);
return ResponseEntity.ok(list); }
}
@GetMapping("/own")
@GetMapping("/own") public ResponseEntity<AufgabenGruppeList> getOwn(@RequestParam("userId") UUID userId) {
public ResponseEntity<AufgabenGruppeList> getOwn(@RequestParam("userId") UUID userId) { AufgabenGruppeList list = new AufgabenGruppeList();
AufgabenGruppeList list = new AufgabenGruppeList(); list.setGruppen(gruppeRepository.findByUserId(userId)
list.setGruppen(gruppeRepository.findByUserId(userId) .stream().map(AufgabenGruppeEntity::toAufgabenGruppeDisplay).toList());
.stream().map(AufgabenGruppeEntity::toAufgabenGruppeDisplay).toList()); return ResponseEntity.ok(list);
return ResponseEntity.ok(list); }
}
/** Gibt eine einzelne Gruppe zurück typunabhängig, da BDSM-Spielstart auch vanillaAvailable-Gruppen laden muss. */
@GetMapping("/{gruppeId}") @GetMapping("/{gruppeId}")
public ResponseEntity<AufgabenGruppe> get(@PathVariable("gruppeId") UUID gruppeId) { public ResponseEntity<AufgabenGruppe> get(@PathVariable("gruppeId") UUID gruppeId) {
return gruppeRepository.findById(gruppeId) return gruppeRepository.findById(gruppeId)
.map(entity -> ResponseEntity.ok(entity.toAufgabenGruppe())) .map(entity -> ResponseEntity.ok(entity.toAufgabenGruppe()))
.orElse(ResponseEntity.noContent().build()); .orElse(ResponseEntity.noContent().build());
} }
// ── Anlegen ── // ── Anlegen ──
@PostMapping @PostMapping
public ResponseEntity<Void> create(@RequestBody AufgabenGruppe gruppe, Principal principal) { public ResponseEntity<Void> create(@RequestBody AufgabenGruppe gruppe, Principal principal) {
if (gruppe.getName() == null || gruppe.getName().isBlank()) { if (gruppe.getName() == null || gruppe.getName().isBlank()) {
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build();
} }
UserEntity user = resolveUser(principal); UserEntity user = resolveUser(principal);
if (user == null) return ResponseEntity.status(401).build(); if (user == null) return ResponseEntity.status(401).build();
if (gruppeRepository.countByUserId(user.getUserId()) >= limitService.maxTaskGroups(user.getUserId())) { if (gruppeRepository.countByUserId(user.getUserId()) >= limitService.maxTaskGroups(user.getUserId())) {
return ResponseEntity.status(409).build(); return ResponseEntity.status(409).build();
} }
AufgabenGruppeEntity entity = AufgabenGruppeEntity.create(gruppe); AufgabenGruppeEntity entity = AufgabenGruppeEntity.create(gruppe);
entity.setUserId(user.getUserId()); entity.setUserId(user.getUserId());
entity.setPrivateGruppe(true); entity.setPrivateGruppe(true);
gruppeRepository.save(entity); // vanillaAvailable kommt aus dem Request-Body (Checkbox im Frontend)
LOGGER.debug("User {} hat AufgabenGruppe '{}' ({}) erstellt", user.getUserId(), entity.getName(), entity.getGruppenId()); gruppeRepository.save(entity);
return ResponseEntity.created( LOGGER.debug("User {} hat BDSM-AufgabenGruppe '{}' ({}) erstellt", user.getUserId(), entity.getName(), entity.getGruppenId());
ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}").buildAndExpand(entity.getGruppenId()).toUri() return ResponseEntity.created(
).build(); ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}").buildAndExpand(entity.getGruppenId()).toUri()
} ).build();
}
// ── Bearbeiten ──
// ── Bearbeiten ──
@PutMapping("/{gruppeId}")
public ResponseEntity<Void> update(@PathVariable("gruppeId") UUID gruppeId, @PutMapping("/{gruppeId}")
@RequestBody AufgabenGruppe gruppe, public ResponseEntity<Void> update(@PathVariable("gruppeId") UUID gruppeId,
Principal principal) { @RequestBody AufgabenGruppe gruppe,
if (gruppe.getName() == null || gruppe.getName().isBlank()) { Principal principal) {
return ResponseEntity.badRequest().build(); if (gruppe.getName() == null || gruppe.getName().isBlank()) {
} return ResponseEntity.badRequest().build();
UserEntity user = resolveUser(principal); }
if (user == null) return ResponseEntity.status(401).build(); UserEntity user = resolveUser(principal);
if (user == null) return ResponseEntity.status(401).build();
AufgabenGruppeEntity entity = gruppeRepository.findById(gruppeId).orElse(null);
if (entity == null) return ResponseEntity.notFound().build(); AufgabenGruppeEntity entity = gruppeRepository.findById(gruppeId).orElse(null);
if (!user.getUserId().equals(entity.getUserId())) return ResponseEntity.status(403).build(); if (entity == null) return ResponseEntity.notFound().build();
if (!user.getUserId().equals(entity.getUserId())) return ResponseEntity.status(403).build();
entity.setName(gruppe.getName().trim());
entity.setBeschreibung(gruppe.getBeschreibung()); entity.setName(gruppe.getName().trim());
entity.setVon(gruppe.getVon()); entity.setBeschreibung(gruppe.getBeschreibung());
entity.setPrivateGruppe(gruppe.isPrivateGruppe()); entity.setVon(gruppe.getVon());
if (gruppe.getBild() != null) { entity.setPrivateGruppe(gruppe.isPrivateGruppe());
entity.setBild(Base64.getDecoder().decode(gruppe.getBild())); entity.setVanillaAvailable(gruppe.isVanillaAvailable());
} if (gruppe.getBild() != null) {
gruppeRepository.save(entity); entity.setBild(Base64.getDecoder().decode(gruppe.getBild()));
LOGGER.debug("User {} hat AufgabenGruppe {} aktualisiert", user.getUserId(), gruppeId); }
return ResponseEntity.ok().build(); gruppeRepository.save(entity);
} LOGGER.debug("User {} hat BDSM-AufgabenGruppe {} aktualisiert", user.getUserId(), gruppeId);
return ResponseEntity.ok().build();
// ── Kopieren (Systemgruppe → eigene) ── }
@PostMapping("/copy/{gruppeId}") // ── Kopieren (Systemgruppe → eigene) ──
public ResponseEntity<Void> copy(@PathVariable("gruppeId") UUID gruppeId, Principal principal) {
UserEntity user = resolveUser(principal); @PostMapping("/copy/{gruppeId}")
if (user == null) return ResponseEntity.status(401).build(); public ResponseEntity<Void> copy(@PathVariable("gruppeId") UUID gruppeId, Principal principal) {
try { UserEntity user = resolveUser(principal);
aufgabenGruppeService.copyGruppe(gruppeId, user.getUserId()); if (user == null) return ResponseEntity.status(401).build();
return ResponseEntity.status(201).build(); try {
} catch (IllegalStateException e) { aufgabenGruppeService.copyGruppe(gruppeId, user.getUserId());
return ResponseEntity.status(409).build(); return ResponseEntity.status(201).build();
} catch (IllegalArgumentException e) { } catch (IllegalStateException e) {
String msg = e.getMessage(); return ResponseEntity.status(409).build();
if (msg != null && msg.contains("nicht gefunden")) return ResponseEntity.notFound().build(); } catch (IllegalArgumentException e) {
return ResponseEntity.status(403).build(); String msg = e.getMessage();
} if (msg != null && msg.contains("nicht gefunden")) return ResponseEntity.notFound().build();
} return ResponseEntity.status(403).build();
}
// ── Löschen ── }
@DeleteMapping("/{gruppeId}") // ── Löschen ──
public ResponseEntity<Void> deleteById(@PathVariable("gruppeId") UUID gruppeId, Principal principal) {
UserEntity user = resolveUser(principal); @DeleteMapping("/{gruppeId}")
if (user == null) return ResponseEntity.status(401).build(); public ResponseEntity<Void> deleteById(@PathVariable("gruppeId") UUID gruppeId, Principal principal) {
UserEntity user = resolveUser(principal);
AufgabenGruppeEntity entity = gruppeRepository.findById(gruppeId).orElse(null); if (user == null) return ResponseEntity.status(401).build();
if (entity == null) return ResponseEntity.noContent().build();
if (!user.getUserId().equals(entity.getUserId())) return ResponseEntity.status(403).build(); AufgabenGruppeEntity entity = gruppeRepository.findById(gruppeId).orElse(null);
if (entity == null) return ResponseEntity.noContent().build();
try { if (!user.getUserId().equals(entity.getUserId())) return ResponseEntity.status(403).build();
aboRepository.deleteByAufgabenGruppe(entity);
aufgabeRepository.deleteAll(entity.getAufgaben()); try {
strafeRepository.deleteAll(entity.getStrafen()); aboRepository.deleteByAufgabenGruppe(entity);
sperreRepository.deleteAll(entity.getSperren()); aufgabeRepository.deleteAll(entity.getAufgaben());
finisherRepository.deleteAll(entity.getFinisher()); strafeRepository.deleteAll(entity.getStrafen());
gruppeRepository.delete(entity); sperreRepository.deleteAll(entity.getSperren());
return ResponseEntity.accepted().build(); finisherRepository.deleteAll(entity.getFinisher());
} catch (Exception e) { gruppeRepository.delete(entity);
LOGGER.error(e.getMessage(), e); return ResponseEntity.accepted().build();
return ResponseEntity.internalServerError().build(); } catch (Exception e) {
} LOGGER.error(e.getMessage(), e);
} return ResponseEntity.internalServerError().build();
}
@DeleteMapping }
public ResponseEntity<Void> delete(@RequestBody AufgabenGruppe gruppe) {
try { @DeleteMapping
gruppeRepository.findById(gruppe.getGruppenId()).ifPresent(gruppeRepository::delete); public ResponseEntity<Void> delete(@RequestBody AufgabenGruppe gruppe) {
return ResponseEntity.accepted().build(); try {
} catch (Exception exception) { gruppeRepository.findById(gruppe.getGruppenId()).ifPresent(gruppeRepository::delete);
LOGGER.error(exception.getMessage(), exception); return ResponseEntity.accepted().build();
return ResponseEntity.internalServerError().build(); } catch (Exception exception) {
} LOGGER.error(exception.getMessage(), exception);
} return ResponseEntity.internalServerError().build();
}
// ── Hilfsmethoden ── }
private UserEntity resolveUser(Principal principal) { // ── Hilfsmethoden ──
if (principal == null) return null;
return userService.requireUser(principal); private UserEntity resolveUser(Principal principal) {
} if (principal == null) return null;
return userService.requireUser(principal);
private AufgabenGruppePage toGruppePage(Page<AufgabenGruppeEntity> page) { }
return toGruppePage(page, false);
} private AufgabenGruppePage toGruppePage(Page<AufgabenGruppeEntity> page) {
return toGruppePage(page, false);
private AufgabenGruppePage toGruppePage(Page<AufgabenGruppeEntity> page, boolean withSubscriberCount) { }
AufgabenGruppePage result = new AufgabenGruppePage();
result.setContent(page.getContent().stream().map(entity -> { private AufgabenGruppePage toGruppePage(Page<AufgabenGruppeEntity> page, boolean withSubscriberCount) {
AufgabenGruppe g = entity.toAufgabenGruppe(); AufgabenGruppePage result = new AufgabenGruppePage();
if (withSubscriberCount) g.setSubscriberCount(aboRepository.countByAufgabenGruppe(entity)); result.setContent(page.getContent().stream().map(entity -> {
return g; AufgabenGruppe g = entity.toAufgabenGruppe();
}).toList()); if (withSubscriberCount) g.setSubscriberCount(aboRepository.countByAufgabenGruppe(entity));
result.setCurrentPage(page.getNumber()); return g;
result.setTotalPages(page.getTotalPages()); }).toList());
result.setTotalElements(page.getTotalElements()); result.setCurrentPage(page.getNumber());
return result; result.setTotalPages(page.getTotalPages());
} result.setTotalElements(page.getTotalElements());
} return result;
}
}

View File

@@ -23,4 +23,5 @@ public class AufgabenGruppe {
private String bild; private String bild;
private long subscriberCount; private long subscriberCount;
private boolean subscribed; private boolean subscribed;
private boolean vanillaAvailable;
} }

View File

@@ -16,4 +16,5 @@ public class AufgabenGruppeDisplay {
private boolean privateGruppe; private boolean privateGruppe;
private String bild; private String bild;
private String von; private String von;
private boolean vanillaAvailable;
} }

View File

@@ -38,6 +38,8 @@ public class AufgabenGruppeEntity {
private byte[] bild; private byte[] bild;
@Column @Column
private String von; private String von;
@Column(columnDefinition = "BOOLEAN DEFAULT FALSE NOT NULL")
private boolean vanillaAvailable = false;
@OneToMany(mappedBy = "aufgabenGruppe") @OneToMany(mappedBy = "aufgabenGruppe")
private List<AufgabeEntity> aufgaben; private List<AufgabeEntity> aufgaben;
@OneToMany(mappedBy = "aufgabenGruppe") @OneToMany(mappedBy = "aufgabenGruppe")
@@ -62,6 +64,7 @@ public class AufgabenGruppeEntity {
gruppe.setPrivateGruppe(privateGruppe); gruppe.setPrivateGruppe(privateGruppe);
gruppe.setBild(bild != null ? Base64.getEncoder().encodeToString(bild) : null); gruppe.setBild(bild != null ? Base64.getEncoder().encodeToString(bild) : null);
gruppe.setVon(von); gruppe.setVon(von);
gruppe.setVanillaAvailable(vanillaAvailable);
gruppe.setAufgaben(aufgaben.stream().map(AufgabeEntity::toAufgabe).toList()); gruppe.setAufgaben(aufgaben.stream().map(AufgabeEntity::toAufgabe).toList());
gruppe.setStrafen(strafen.stream().map(StrafeEntity::toStrafe).toList()); gruppe.setStrafen(strafen.stream().map(StrafeEntity::toStrafe).toList());
gruppe.setSperren(sperren.stream().map(SperreEntity::toSperre).toList()); gruppe.setSperren(sperren.stream().map(SperreEntity::toSperre).toList());
@@ -78,6 +81,7 @@ public class AufgabenGruppeEntity {
entity.setPrivateGruppe(gruppe.isPrivateGruppe()); entity.setPrivateGruppe(gruppe.isPrivateGruppe());
entity.setBild(gruppe.getBild() != null ? Base64.getDecoder().decode(gruppe.getBild()) : null); entity.setBild(gruppe.getBild() != null ? Base64.getDecoder().decode(gruppe.getBild()) : null);
entity.setVon(gruppe.getVon()); entity.setVon(gruppe.getVon());
entity.setVanillaAvailable(gruppe.isVanillaAvailable());
return entity; return entity;
} }
@@ -90,6 +94,7 @@ public class AufgabenGruppeEntity {
display.setPrivateGruppe(privateGruppe); display.setPrivateGruppe(privateGruppe);
display.setBild(bild != null ? Base64.getEncoder().encodeToString(bild) : null); display.setBild(bild != null ? Base64.getEncoder().encodeToString(bild) : null);
display.setVon(von); display.setVon(von);
display.setVanillaAvailable(vanillaAvailable);
return display; return display;
} }
} }

View File

@@ -1,47 +1,46 @@
package de.oaa.xxx.games.common.repository; package de.oaa.xxx.games.common.repository;
import de.oaa.xxx.games.common.entity.AufgabenGruppeEntity;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param; import org.springframework.data.repository.query.Param;
import de.oaa.xxx.games.common.entity.AufgabenGruppeEntity;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
public interface AufgabenGruppeRepository extends JpaRepository<AufgabenGruppeEntity, UUID> { public interface AufgabenGruppeRepository extends JpaRepository<AufgabenGruppeEntity, UUID> {
@Query("select age from AufgabenGruppeEntity age where age.userId = :userId") // ── Account-Löschung (UserService) ────────────────────────────────────────
@Query("select g from AufgabenGruppeEntity g where g.userId = :userId")
List<AufgabenGruppeEntity> findByUserId(@Param("userId") UUID userId); List<AufgabenGruppeEntity> findByUserId(@Param("userId") UUID userId);
long countByUserId(UUID userId); long countByUserId(UUID userId);
Page<AufgabenGruppeEntity> findByUserIdIsNull(Pageable pageable); // ── BDSM-Verwaltung: alle Gruppen paginiert ───────────────────────────────
Page<AufgabenGruppeEntity> findByUserId(UUID userId, Pageable pageable); Page<AufgabenGruppeEntity> findByUserId(UUID userId, Pageable pageable);
@Query("select age from AufgabenGruppeEntity age where (age.privateGruppe = false or age.userId = :userId) and (:search is null or age.name like :search)") Page<AufgabenGruppeEntity> findByUserIdIsNull(Pageable pageable);
List<AufgabenGruppeEntity> listWithUserAndSearch(@Param("userId") UUID userId, @Param("search") String search, PageRequest pageable);
@Query("select age from AufgabenGruppeEntity age where age.privateGruppe = false and (:search is null or age.name like :search)") // ── Vanilla-Verwaltung: nur vanillaAvailable=true, mit Inhalt ─────────────
List<AufgabenGruppeEntity> listPublicWithSearch(@Param("search") String search, PageRequest pageable);
@Query("select age from AufgabenGruppeEntity age where age.privateGruppe = false and age.userId is not null and age.userId <> :userId and (:name is null or lower(age.name) like lower(:name))") @Query("SELECT g FROM AufgabenGruppeEntity g WHERE g.userId = :userId AND g.vanillaAvailable = true AND (g.aufgaben IS NOT EMPTY OR g.finisher IS NOT EMPTY)")
List<AufgabenGruppeEntity> findPublicFromOthers(@Param("userId") UUID userId, @Param("name") String name); Page<AufgabenGruppeEntity> findByUserIdAndVanillaAvailableTrueWithContent(@Param("userId") UUID userId, Pageable pageable);
@Query("SELECT g FROM AufgabenGruppeEntity g WHERE (g.privateGruppe = false OR g.userId = :userId) AND g.strafen IS EMPTY AND g.sperren IS EMPTY AND (:search IS NULL OR g.name LIKE :search)") @Query("SELECT g FROM AufgabenGruppeEntity g WHERE g.userId IS NULL AND g.vanillaAvailable = true AND (g.aufgaben IS NOT EMPTY OR g.finisher IS NOT EMPTY)")
List<AufgabenGruppeEntity> listVanillaSafeWithUserAndSearch(@Param("userId") UUID userId, @Param("search") String search, PageRequest pageable); Page<AufgabenGruppeEntity> findSystemGroupsByVanillaAvailableTrueWithContent(Pageable pageable);
@Query("SELECT g FROM AufgabenGruppeEntity g WHERE g.privateGruppe = false AND g.userId IS NOT NULL AND g.userId <> :userId AND g.strafen IS EMPTY AND g.sperren IS EMPTY AND (:name IS NULL OR LOWER(g.name) LIKE LOWER(:name))") // ── Spielstart-Auswahl ────────────────────────────────────────────────────
List<AufgabenGruppeEntity> findVanillaSafePublicFromOthers(@Param("userId") UUID userId, @Param("name") String name);
@Query("SELECT g FROM AufgabenGruppeEntity g WHERE g.userId = :userId AND (g.aufgaben IS NOT EMPTY OR g.finisher IS NOT EMPTY)") /** Nur vanillaAvailable-Gruppen für Vanilla-Spielstart und Vanilla-Suche. */
Page<AufgabenGruppeEntity> findByUserIdWithContent(@Param("userId") UUID userId, Pageable pageable); @Query("select g from AufgabenGruppeEntity g where g.vanillaAvailable = true and (g.privateGruppe = false or g.userId = :userId) and (:search is null or g.name like :search)")
List<AufgabenGruppeEntity> listVanillaAvailableWithUserAndSearch(@Param("userId") UUID userId, @Param("search") String search, PageRequest pageable);
@Query("SELECT g FROM AufgabenGruppeEntity g WHERE g.userId IS NULL AND (g.aufgaben IS NOT EMPTY OR g.finisher IS NOT EMPTY)") /** Alle Gruppen für BDSM-Spielstart (vanillaAvailable-Gruppen werden im Frontend hervorgehoben). */
Page<AufgabenGruppeEntity> findSystemGroupsWithContent(Pageable pageable); @Query("select g from AufgabenGruppeEntity g where (g.privateGruppe = false or g.userId = :userId) and (:search is null or g.name like :search)")
List<AufgabenGruppeEntity> listAllWithUserAndSearch(@Param("userId") UUID userId, @Param("search") String search, PageRequest pageable);
} }

View File

@@ -6,9 +6,7 @@ import java.util.UUID;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.DeleteMapping;
@@ -58,16 +56,21 @@ public class VanillaAboController {
Principal principal) { Principal principal) {
UserEntity user = userService.requireUser(principal); UserEntity user = userService.requireUser(principal);
Page<GruppenAboEntity> dbPage = aboRepository.findByUserIdWithContent( List<AufgabenGruppe> all = aboRepository.findByUserId(user.getUserId()).stream()
user.getUserId(), PageRequest.of(page, size, Sort.by("aufgabenGruppe.name"))); .map(GruppenAboEntity::getAufgabenGruppe)
List<AufgabenGruppe> dtos = dbPage.getContent().stream() .filter(g -> g.isVanillaAvailable()
.map(a -> enrich(a.getAufgabenGruppe(), user.getUserId(), true)) && !g.isPrivateGruppe()
&& (!g.getAufgaben().isEmpty() || !g.getFinisher().isEmpty()))
.map(g -> enrich(g, user.getUserId(), true))
.sorted(java.util.Comparator.comparing(AufgabenGruppe::getName, String.CASE_INSENSITIVE_ORDER))
.toList(); .toList();
int total = all.size();
int start = page * size;
AufgabenGruppePage result = new AufgabenGruppePage(); AufgabenGruppePage result = new AufgabenGruppePage();
result.setContent(dtos); result.setContent(start >= total ? List.of() : all.subList(start, Math.min(start + size, total)));
result.setCurrentPage(dbPage.getNumber()); result.setCurrentPage(page);
result.setTotalPages(dbPage.getTotalPages()); result.setTotalPages(total == 0 ? 1 : (int) Math.ceil((double) total / size));
result.setTotalElements(dbPage.getTotalElements()); result.setTotalElements(total);
return ResponseEntity.ok(result); return ResponseEntity.ok(result);
} }
@@ -84,7 +87,8 @@ public class VanillaAboController {
String namePattern = name != null && !name.isBlank() ? "%" + name.trim() + "%" : null; String namePattern = name != null && !name.isBlank() ? "%" + name.trim() + "%" : null;
List<AufgabenGruppe> dtos = gruppeRepository List<AufgabenGruppe> dtos = gruppeRepository
.findVanillaSafePublicFromOthers(user.getUserId(), namePattern).stream() .listVanillaAvailableWithUserAndSearch(user.getUserId(), namePattern, PageRequest.of(0, 500)).stream()
.filter(g -> !g.isPrivateGruppe() && g.getUserId() != null && !g.getUserId().equals(user.getUserId()))
.map(g -> enrich(g, user.getUserId(), aboRepository.existsByUserIdAndAufgabenGruppe(user.getUserId(), g))) .map(g -> enrich(g, user.getUserId(), aboRepository.existsByUserIdAndAufgabenGruppe(user.getUserId(), g)))
.sorted(java.util.Comparator.comparingLong(AufgabenGruppe::getSubscriberCount).reversed() .sorted(java.util.Comparator.comparingLong(AufgabenGruppe::getSubscriberCount).reversed()
.thenComparing(AufgabenGruppe::getName, String.CASE_INSENSITIVE_ORDER)) .thenComparing(AufgabenGruppe::getName, String.CASE_INSENSITIVE_ORDER))
@@ -111,8 +115,7 @@ public class VanillaAboController {
if (gruppe == null || gruppe.isPrivateGruppe() || user.getUserId().equals(gruppe.getUserId())) { if (gruppe == null || gruppe.isPrivateGruppe() || user.getUserId().equals(gruppe.getUserId())) {
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build();
} }
// Vanilla-safe validation if (!gruppe.isVanillaAvailable()) {
if (!gruppe.getStrafen().isEmpty() || !gruppe.getSperren().isEmpty()) {
return ResponseEntity.status(403).build(); return ResponseEntity.status(403).build();
} }
if (aboRepository.existsByUserIdAndAufgabenGruppe(user.getUserId(), gruppe)) { if (aboRepository.existsByUserIdAndAufgabenGruppe(user.getUserId(), gruppe)) {

View File

@@ -1,9 +1,19 @@
package de.oaa.xxx.games.vanilla.controller; package de.oaa.xxx.games.vanilla.controller;
import java.security.Principal; import de.oaa.xxx.games.common.aufgaben.AufgabenGruppe;
import java.util.Base64; import de.oaa.xxx.games.common.aufgaben.AufgabenGruppeList;
import java.util.UUID; import de.oaa.xxx.games.common.aufgaben.AufgabenGruppePage;
import de.oaa.xxx.games.common.aufgaben.AufgabenGruppeService;
import de.oaa.xxx.games.common.entity.AufgabenGruppeEntity;
import de.oaa.xxx.games.common.repository.AufgabeRepository;
import de.oaa.xxx.games.common.repository.AufgabenGruppeRepository;
import de.oaa.xxx.games.common.repository.FinisherRepository;
import de.oaa.xxx.games.common.repository.GruppenAboRepository;
import de.oaa.xxx.games.common.repository.SperreRepository;
import de.oaa.xxx.games.common.repository.StrafeRepository;
import de.oaa.xxx.subscription.SubscriptionLimitService;
import de.oaa.xxx.user.UserEntity;
import de.oaa.xxx.user.UserService;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
@@ -22,20 +32,9 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder; import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import de.oaa.xxx.games.common.aufgaben.AufgabenGruppe; import java.security.Principal;
import de.oaa.xxx.games.common.aufgaben.AufgabenGruppeList; import java.util.Base64;
import de.oaa.xxx.games.common.aufgaben.AufgabenGruppePage; import java.util.UUID;
import de.oaa.xxx.games.common.aufgaben.AufgabenGruppeService;
import de.oaa.xxx.games.common.entity.AufgabenGruppeEntity;
import de.oaa.xxx.games.common.repository.AufgabeRepository;
import de.oaa.xxx.games.common.repository.AufgabenGruppeRepository;
import de.oaa.xxx.games.common.repository.FinisherRepository;
import de.oaa.xxx.games.common.repository.GruppenAboRepository;
import de.oaa.xxx.games.common.repository.SperreRepository;
import de.oaa.xxx.games.common.repository.StrafeRepository;
import de.oaa.xxx.subscription.SubscriptionLimitService;
import de.oaa.xxx.user.UserEntity;
import de.oaa.xxx.user.UserService;
@RestController @RestController
@RequestMapping("/vanilla/gruppe") @RequestMapping("/vanilla/gruppe")
@@ -84,7 +83,7 @@ public class VanillaAufgabenGruppeController {
Principal principal) { Principal principal) {
UserEntity user = resolveUser(principal); UserEntity user = resolveUser(principal);
if (user == null) return ResponseEntity.status(401).build(); if (user == null) return ResponseEntity.status(401).build();
Page<AufgabenGruppeEntity> dbPage = gruppeRepository.findByUserIdWithContent( Page<AufgabenGruppeEntity> dbPage = gruppeRepository.findByUserIdAndVanillaAvailableTrueWithContent(
user.getUserId(), PageRequest.of(page, size, Sort.by("name"))); user.getUserId(), PageRequest.of(page, size, Sort.by("name")));
AufgabenGruppePage result = new AufgabenGruppePage(); AufgabenGruppePage result = new AufgabenGruppePage();
result.setContent(dbPage.getContent().stream().map(entity -> { result.setContent(dbPage.getContent().stream().map(entity -> {
@@ -102,11 +101,10 @@ public class VanillaAufgabenGruppeController {
public ResponseEntity<AufgabenGruppePage> listSystem( public ResponseEntity<AufgabenGruppePage> listSystem(
@RequestParam(name = "page", defaultValue = "0") int page, @RequestParam(name = "page", defaultValue = "0") int page,
@RequestParam(name = "size", defaultValue = "" + DEFAULT_PAGE_SIZE) int size) { @RequestParam(name = "size", defaultValue = "" + DEFAULT_PAGE_SIZE) int size) {
Page<AufgabenGruppeEntity> dbPage = gruppeRepository.findSystemGroupsWithContent( Page<AufgabenGruppeEntity> dbPage = gruppeRepository.findSystemGroupsByVanillaAvailableTrueWithContent(
PageRequest.of(page, size, Sort.by("name"))); PageRequest.of(page, size, Sort.by("name")));
AufgabenGruppePage r = new AufgabenGruppePage(); AufgabenGruppePage r = new AufgabenGruppePage();
r.setContent(dbPage.getContent().stream() r.setContent(dbPage.getContent().stream().map(AufgabenGruppeEntity::toAufgabenGruppe).toList());
.map(AufgabenGruppeEntity::toAufgabenGruppe).toList());
r.setCurrentPage(dbPage.getNumber()); r.setCurrentPage(dbPage.getNumber());
r.setTotalPages(dbPage.getTotalPages()); r.setTotalPages(dbPage.getTotalPages());
r.setTotalElements(dbPage.getTotalElements()); r.setTotalElements(dbPage.getTotalElements());
@@ -120,7 +118,8 @@ public class VanillaAufgabenGruppeController {
UUID userId = userService.requireUser(principal).getUserId(); UUID userId = userService.requireUser(principal).getUserId();
String searchPattern = search != null ? "%" + search + "%" : null; String searchPattern = search != null ? "%" + search + "%" : null;
AufgabenGruppeList list = new AufgabenGruppeList(); AufgabenGruppeList list = new AufgabenGruppeList();
list.setGruppen(gruppeRepository.listVanillaSafeWithUserAndSearch(userId, searchPattern, PageRequest.of(0, 500)) list.setGruppen(gruppeRepository.listVanillaAvailableWithUserAndSearch(
userId, searchPattern, PageRequest.of(0, 500))
.stream().map(AufgabenGruppeEntity::toAufgabenGruppeDisplay).toList()); .stream().map(AufgabenGruppeEntity::toAufgabenGruppeDisplay).toList());
return ResponseEntity.ok(list); return ResponseEntity.ok(list);
} }
@@ -129,7 +128,7 @@ public class VanillaAufgabenGruppeController {
public ResponseEntity<AufgabenGruppeList> getOwn(@RequestParam("userId") UUID userId) { public ResponseEntity<AufgabenGruppeList> getOwn(@RequestParam("userId") UUID userId) {
AufgabenGruppeList list = new AufgabenGruppeList(); AufgabenGruppeList list = new AufgabenGruppeList();
list.setGruppen(gruppeRepository.findByUserId(userId).stream() list.setGruppen(gruppeRepository.findByUserId(userId).stream()
.filter(g -> g.getStrafen().isEmpty() && g.getSperren().isEmpty()) .filter(AufgabenGruppeEntity::isVanillaAvailable)
.map(AufgabenGruppeEntity::toAufgabenGruppeDisplay).toList()); .map(AufgabenGruppeEntity::toAufgabenGruppeDisplay).toList());
return ResponseEntity.ok(list); return ResponseEntity.ok(list);
} }
@@ -137,7 +136,7 @@ public class VanillaAufgabenGruppeController {
@GetMapping("/{gruppeId}") @GetMapping("/{gruppeId}")
public ResponseEntity<AufgabenGruppe> get(@PathVariable("gruppeId") UUID gruppeId) { public ResponseEntity<AufgabenGruppe> get(@PathVariable("gruppeId") UUID gruppeId) {
return gruppeRepository.findById(gruppeId) return gruppeRepository.findById(gruppeId)
.filter(g -> g.getStrafen().isEmpty() && g.getSperren().isEmpty()) .filter(AufgabenGruppeEntity::isVanillaAvailable)
.map(entity -> ResponseEntity.ok(entity.toAufgabenGruppe())) .map(entity -> ResponseEntity.ok(entity.toAufgabenGruppe()))
.orElse(ResponseEntity.status(403).build()); .orElse(ResponseEntity.status(403).build());
} }
@@ -159,6 +158,7 @@ public class VanillaAufgabenGruppeController {
AufgabenGruppeEntity entity = AufgabenGruppeEntity.create(gruppe); AufgabenGruppeEntity entity = AufgabenGruppeEntity.create(gruppe);
entity.setUserId(user.getUserId()); entity.setUserId(user.getUserId());
entity.setPrivateGruppe(true); entity.setPrivateGruppe(true);
entity.setVanillaAvailable(true);
gruppeRepository.save(entity); gruppeRepository.save(entity);
LOGGER.debug("User {} hat Vanilla-AufgabenGruppe '{}' ({}) erstellt", user.getUserId(), entity.getName(), entity.getGruppenId()); LOGGER.debug("User {} hat Vanilla-AufgabenGruppe '{}' ({}) erstellt", user.getUserId(), entity.getName(), entity.getGruppenId());
return ResponseEntity.created( return ResponseEntity.created(
@@ -181,8 +181,7 @@ public class VanillaAufgabenGruppeController {
AufgabenGruppeEntity entity = gruppeRepository.findById(gruppeId).orElse(null); AufgabenGruppeEntity entity = gruppeRepository.findById(gruppeId).orElse(null);
if (entity == null) return ResponseEntity.notFound().build(); if (entity == null) return ResponseEntity.notFound().build();
if (!user.getUserId().equals(entity.getUserId())) return ResponseEntity.status(403).build(); if (!user.getUserId().equals(entity.getUserId())) return ResponseEntity.status(403).build();
// Vanilla-safe check: cannot edit a non-vanilla-safe group if (!entity.isVanillaAvailable()) return ResponseEntity.status(403).build();
if (!entity.getStrafen().isEmpty() || !entity.getSperren().isEmpty()) return ResponseEntity.status(403).build();
entity.setName(gruppe.getName().trim()); entity.setName(gruppe.getName().trim());
entity.setBeschreibung(gruppe.getBeschreibung()); entity.setBeschreibung(gruppe.getBeschreibung());
@@ -202,10 +201,9 @@ public class VanillaAufgabenGruppeController {
public ResponseEntity<Void> copy(@PathVariable("gruppeId") UUID gruppeId, Principal principal) { public ResponseEntity<Void> copy(@PathVariable("gruppeId") UUID gruppeId, Principal principal) {
UserEntity user = resolveUser(principal); UserEntity user = resolveUser(principal);
if (user == null) return ResponseEntity.status(401).build(); if (user == null) return ResponseEntity.status(401).build();
// Only allow copying vanilla-safe groups
AufgabenGruppeEntity source = gruppeRepository.findById(gruppeId).orElse(null); AufgabenGruppeEntity source = gruppeRepository.findById(gruppeId).orElse(null);
if (source == null) return ResponseEntity.notFound().build(); if (source == null) return ResponseEntity.notFound().build();
if (!source.getStrafen().isEmpty() || !source.getSperren().isEmpty()) return ResponseEntity.status(403).build(); if (!source.isVanillaAvailable()) return ResponseEntity.status(403).build();
try { try {
aufgabenGruppeService.copyGruppe(gruppeId, user.getUserId()); aufgabenGruppeService.copyGruppe(gruppeId, user.getUserId());
return ResponseEntity.status(201).build(); return ResponseEntity.status(201).build();
@@ -228,8 +226,7 @@ public class VanillaAufgabenGruppeController {
AufgabenGruppeEntity entity = gruppeRepository.findById(gruppeId).orElse(null); AufgabenGruppeEntity entity = gruppeRepository.findById(gruppeId).orElse(null);
if (entity == null) return ResponseEntity.noContent().build(); if (entity == null) return ResponseEntity.noContent().build();
if (!user.getUserId().equals(entity.getUserId())) return ResponseEntity.status(403).build(); if (!user.getUserId().equals(entity.getUserId())) return ResponseEntity.status(403).build();
// Only allow deletion of vanilla-safe groups if (!entity.isVanillaAvailable()) return ResponseEntity.status(403).build();
if (!entity.getStrafen().isEmpty() || !entity.getSperren().isEmpty()) return ResponseEntity.status(403).build();
try { try {
aboRepository.deleteByAufgabenGruppe(entity); aboRepository.deleteByAufgabenGruppe(entity);
@@ -249,7 +246,7 @@ public class VanillaAufgabenGruppeController {
public ResponseEntity<Void> delete(@RequestBody AufgabenGruppe gruppe) { public ResponseEntity<Void> delete(@RequestBody AufgabenGruppe gruppe) {
try { try {
gruppeRepository.findById(gruppe.getGruppenId()).ifPresent(entity -> { gruppeRepository.findById(gruppe.getGruppenId()).ifPresent(entity -> {
if (entity.getStrafen().isEmpty() && entity.getSperren().isEmpty()) { if (entity.isVanillaAvailable()) {
gruppeRepository.delete(entity); gruppeRepository.delete(entity);
} }
}); });
@@ -266,5 +263,4 @@ public class VanillaAufgabenGruppeController {
if (principal == null) return null; if (principal == null) return null;
return userService.requireUser(principal); return userService.requireUser(principal);
} }
} }

View File

@@ -41,7 +41,7 @@ public class NotificationController {
n.put("text", m.getText()); n.put("text", m.getText());
n.put("sentAt", m.getSentAt().toString()); n.put("sentAt", m.getSentAt().toString());
n.put("read", m.getReadAt() != null); n.put("read", m.getReadAt() != null);
n.put("targetUrl", m.getTargetUrl() != null ? m.getTargetUrl() : ""); n.put("targetUrl", sanitizeTargetUrl(m.getTargetUrl()));
userRepository.findById(m.getSenderId()).ifPresent(sender -> { userRepository.findById(m.getSenderId()).ifPresent(sender -> {
n.put("senderName", sender.getName()); n.put("senderName", sender.getName());
n.put("senderAvatar", sender.getProfilePicture() != null ? sender.getProfilePicture() : ""); n.put("senderAvatar", sender.getProfilePicture() != null ? sender.getProfilePicture() : "");
@@ -67,6 +67,12 @@ public class NotificationController {
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
/** Erlaubt nur relative Pfade (beginnen mit '/') verhindert javascript:-URLs und externe Redirects. */
private String sanitizeTargetUrl(String url) {
if (url == null || url.isBlank()) return "";
return url.startsWith("/") ? url : "";
}
@Transactional @Transactional
@PostMapping("/read-all") @PostMapping("/read-all")
public ResponseEntity<Void> markAllRead(Principal principal) { public ResponseEntity<Void> markAllRead(Principal principal) {

View File

@@ -283,7 +283,8 @@ public class UserController {
} }
@GetMapping("/{userId}/bdsm-defaults") @GetMapping("/{userId}/bdsm-defaults")
public ResponseEntity<Map<String, Object>> getBdsmDefaultsForUser(@PathVariable("userId") UUID userId) { public ResponseEntity<Map<String, Object>> getBdsmDefaultsForUser(@PathVariable("userId") UUID userId, Principal principal) {
userService.requireUser(principal);
var userOpt = userRepository.findById(userId); var userOpt = userRepository.findById(userId);
if (userOpt.isEmpty()) return ResponseEntity.notFound().build(); if (userOpt.isEmpty()) return ResponseEntity.notFound().build();
UserEntity user = userOpt.get(); UserEntity user = userOpt.get();

View File

@@ -120,7 +120,8 @@
.gruppe-info { font-size:0.75rem; color:var(--color-muted); margin-top:0.2rem; } .gruppe-info { font-size:0.75rem; color:var(--color-muted); margin-top:0.2rem; }
.gruppe-badges { display:flex; gap:0.3rem; margin-top:0.25rem; flex-wrap:wrap; } .gruppe-badges { display:flex; gap:0.3rem; margin-top:0.25rem; flex-wrap:wrap; }
.gruppe-badge { font-size:0.65rem; padding:0.1rem 0.4rem; border-radius:20px; background:rgba(255,255,255,0.07); color:var(--color-muted); } .gruppe-badge { font-size:0.65rem; padding:0.1rem 0.4rem; border-radius:20px; background:rgba(255,255,255,0.07); color:var(--color-muted); }
.gruppe-badge-public { background:rgba(46,204,113,0.15); color:var(--color-success); } .gruppe-badge-public { background:rgba(46,204,113,0.15); color:var(--color-success); }
.gruppe-badge-vanilla { background:#e8f5e9; color:#2e7d32; border:1px solid #a5d6a7; }
.gruppe-toggle { font-size:0.75rem; color:var(--color-muted); flex-shrink:0; transition:transform 0.2s; } .gruppe-toggle { font-size:0.75rem; color:var(--color-muted); flex-shrink:0; transition:transform 0.2s; }
.gruppe-card.open .gruppe-toggle { transform:rotate(90deg); } .gruppe-card.open .gruppe-toggle { transform:rotate(90deg); }
.gruppe-body { border-top:1px solid var(--color-secondary); padding:1rem 1rem 0.75rem; } .gruppe-body { border-top:1px solid var(--color-secondary); padding:1rem 1rem 0.75rem; }
@@ -277,6 +278,12 @@
<span style="font-size:0.78rem; color:var(--color-muted);">Aktuelles Bild neues wählen zum Ersetzen</span> <span style="font-size:0.78rem; color:var(--color-muted);">Aktuelles Bild neues wählen zum Ersetzen</span>
</div> </div>
<input type="file" id="gBild" accept="image/*"> <input type="file" id="gBild" accept="image/*">
<label style="margin-top:0.5rem;">
<span class="modal-check">
<input type="checkbox" id="gVanilla">
Auch für Vanilla-Game verfügbar
</span>
</label>
<div class="modal-error" id="gruppeModalError"></div> <div class="modal-error" id="gruppeModalError"></div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn-cancel" id="gruppeModalCancel">Abbrechen</button> <button class="btn-cancel" id="gruppeModalCancel">Abbrechen</button>
@@ -986,7 +993,7 @@ function renderAdminGruppen(gruppen) {
<div class="gruppe-meta"> <div class="gruppe-meta">
<div class="gruppe-name">${esc(g.name)}</div> <div class="gruppe-name">${esc(g.name)}</div>
<div class="gruppe-info">${g.von ? esc(g.von) + (counts ? ' · ' : '') : ''}${counts || 'Keine Einträge'}</div> <div class="gruppe-info">${g.von ? esc(g.von) + (counts ? ' · ' : '') : ''}${counts || 'Keine Einträge'}</div>
<div class="gruppe-badges"><span class="gruppe-badge gruppe-badge-public">Öffentlich</span></div> <div class="gruppe-badges"><span class="gruppe-badge gruppe-badge-public">Öffentlich</span>${g.vanillaAvailable ? '<span class="gruppe-badge gruppe-badge-vanilla">Vanilla</span>' : ''}</div>
</div> </div>
<span class="gruppe-toggle">▶</span> <span class="gruppe-toggle">▶</span>
</div> </div>
@@ -1262,6 +1269,7 @@ function openGruppeModal(editId) {
document.getElementById('gName').value = g.name || ''; document.getElementById('gName').value = g.name || '';
document.getElementById('gVon').value = g.von || ''; document.getElementById('gVon').value = g.von || '';
document.getElementById('gDesc').value = g.beschreibung || ''; document.getElementById('gDesc').value = g.beschreibung || '';
document.getElementById('gVanilla').checked = g.vanillaAvailable || false;
const imgWrap = document.getElementById('gCurrentImgWrap'); const imgWrap = document.getElementById('gCurrentImgWrap');
if (g.bild) { document.getElementById('gCurrentImg').src = 'data:image/png;base64,' + g.bild; imgWrap.style.display = 'flex'; } if (g.bild) { document.getElementById('gCurrentImg').src = 'data:image/png;base64,' + g.bild; imgWrap.style.display = 'flex'; }
else imgWrap.style.display = 'none'; else imgWrap.style.display = 'none';
@@ -1270,6 +1278,7 @@ function openGruppeModal(editId) {
document.getElementById('gName').value = ''; document.getElementById('gName').value = '';
document.getElementById('gVon').value = ''; document.getElementById('gVon').value = '';
document.getElementById('gDesc').value = ''; document.getElementById('gDesc').value = '';
document.getElementById('gVanilla').checked = false;
document.getElementById('gCurrentImgWrap').style.display = 'none'; document.getElementById('gCurrentImgWrap').style.display = 'none';
} }
gruppeModal.classList.add('open'); gruppeModal.classList.add('open');
@@ -1348,7 +1357,7 @@ gruppeModalSave.addEventListener('click', async () => {
let bildBase64 = null; let bildBase64 = null;
const fi = document.getElementById('gBild'); const fi = document.getElementById('gBild');
if (fi.files.length > 0) bildBase64 = await toBase64(fi.files[0]); if (fi.files.length > 0) bildBase64 = await toBase64(fi.files[0]);
const payload = { name, von: document.getElementById('gVon').value.trim() || null, beschreibung: document.getElementById('gDesc').value.trim() || null, bild: bildBase64 }; const payload = { name, von: document.getElementById('gVon').value.trim() || null, beschreibung: document.getElementById('gDesc').value.trim() || null, vanillaAvailable: document.getElementById('gVanilla').checked, bild: bildBase64 };
const isEdit = currentEditGruppeId != null; const isEdit = currentEditGruppeId != null;
fetch(isEdit ? `/admin/aufgabengruppen/${currentEditGruppeId}` : '/admin/aufgabengruppen', { fetch(isEdit ? `/admin/aufgabengruppen/${currentEditGruppeId}` : '/admin/aufgabengruppen', {
method: isEdit ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) method: isEdit ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload)

View File

@@ -195,6 +195,7 @@ function renderPage(evt) {
const totalAttendees = (evt.attendees || []).length; const totalAttendees = (evt.attendees || []).length;
const attending = evt.attendingMe; const attending = evt.attendingMe;
const isPast = new Date(evt.startAt) < new Date();
document.getElementById('content').innerHTML = ` document.getElementById('content').innerHTML = `
<div class="evt-header"> <div class="evt-header">
@@ -211,11 +212,13 @@ function renderPage(evt) {
<button class="btn" style="background:#c0392b;font-size:0.85rem;" onclick="openDeleteConfirm()">Löschen</button> <button class="btn" style="background:#c0392b;font-size:0.85rem;" onclick="openDeleteConfirm()">Löschen</button>
</div>` : ''} </div>` : ''}
<div style="display:flex;align-items:center;gap:0.4rem;flex-wrap:wrap;"> <div style="display:flex;align-items:center;gap:0.4rem;flex-wrap:wrap;">
<button class="btn" id="attendBtn" ${isPast
style="${attending ? 'background:var(--color-secondary);color:var(--color-text);' : ''}" ? `<span style="color:var(--color-muted);font-size:0.85rem;">Veranstaltung bereits beendet</span>`
onclick="toggleAttend()"> : `<button class="btn" id="attendBtn"
${attending ? '✓ Ich bin dabei' : '+ Ich bin dabei'} style="${attending ? 'background:var(--color-secondary);color:var(--color-text);' : ''}"
</button> onclick="toggleAttend()">
${attending ? '✓ Ich bin dabei' : '+ Ich bin dabei'}
</button>`}
<span style="color:var(--color-muted);font-size:0.85rem;" id="attendCount">${totalAttendees} Teilnehmer*in(nen)</span> <span style="color:var(--color-muted);font-size:0.85rem;" id="attendCount">${totalAttendees} Teilnehmer*in(nen)</span>
</div> </div>
</div> </div>

View File

@@ -445,6 +445,9 @@ function renderPage() {
${isOwner ? `<button class="btn" style="font-size:0.8rem;" onclick="openEventModal()">+ Veranstaltung erstellen</button>` : ''} ${isOwner ? `<button class="btn" style="font-size:0.8rem;" onclick="openEventModal()">+ Veranstaltung erstellen</button>` : ''}
</div> </div>
<div class="event-list" id="eventList"><p style="color:var(--color-muted);font-size:0.9rem;">Wird geladen…</p></div> <div class="event-list" id="eventList"><p style="color:var(--color-muted);font-size:0.9rem;">Wird geladen…</p></div>
<div id="allEventsLinkWrap" style="display:none;margin-top:0.75rem;">
<a id="allEventsLink" href="#" class="btn" style="display:inline-block;font-size:0.85rem;background:var(--color-secondary);color:var(--color-text);text-decoration:none;padding:0.45rem 1rem;border-radius:6px;">Alle Events anzeigen →</a>
</div>
<div id="pastEventsSection" style="display:none;"> <div id="pastEventsSection" style="display:none;">
<div class="section-title" style="margin-top:1.5rem;">Vergangene Veranstaltungen</div> <div class="section-title" style="margin-top:1.5rem;">Vergangene Veranstaltungen</div>
<div class="event-list" id="pastEventList"></div> <div class="event-list" id="pastEventList"></div>
@@ -517,8 +520,8 @@ function renderPage() {
${locHeaderHtml} ${locHeaderHtml}
${hoursHtml} ${hoursHtml}
${gallerySection} ${gallerySection}
${feedSection} ${eventsSection}
${eventsSection}`; ${feedSection}`;
} }
} }
@@ -587,15 +590,27 @@ async function loadEvents() {
if (!list) return; if (!list) return;
const now = new Date(); const now = new Date();
const future = events.filter(e => new Date(e.startAt) >= now); const future = events.filter(e => new Date(e.startAt) >= now)
.sort((a, b) => new Date(a.startAt) - new Date(b.startAt));
const past = events.filter(e => new Date(e.startAt) < now) const past = events.filter(e => new Date(e.startAt) < now)
.slice(-5) // letzte 5 .slice(-3) // letzte 3
.reverse(); // neueste zuerst .reverse(); // neueste zuerst
list.innerHTML = future.length const preview = future.slice(0, 3);
? future.map(e => buildEventCard(e, false)).join('') list.innerHTML = preview.length
? preview.map(e => buildEventCard(e, false)).join('')
: '<p style="color:var(--color-muted);font-size:0.9rem;">Keine bevorstehenden Veranstaltungen.</p>'; : '<p style="color:var(--color-muted);font-size:0.9rem;">Keine bevorstehenden Veranstaltungen.</p>';
const linkWrap = document.getElementById('allEventsLinkWrap');
if (linkWrap) {
if (future.length > 3) {
document.getElementById('allEventsLink').href = `/community/location-events.html?id=${locationId}`;
linkWrap.style.display = '';
} else {
linkWrap.style.display = 'none';
}
}
const pastSection = document.getElementById('pastEventsSection'); const pastSection = document.getElementById('pastEventsSection');
if (past.length && pastSection) { if (past.length && pastSection) {
document.getElementById('pastEventList').innerHTML = past.map(e => buildEventCard(e, true)).join(''); document.getElementById('pastEventList').innerHTML = past.map(e => buildEventCard(e, true)).join('');
@@ -1358,11 +1373,15 @@ function renderLocPost(p) {
<button class="post-action-btn post-delete" onclick="event.stopPropagation();deleteLocPost('${p.postId}')" title="Löschen">🗑</button> <button class="post-action-btn post-delete" onclick="event.stopPropagation();deleteLocPost('${p.postId}')" title="Löschen">🗑</button>
</div>` : ''; </div>` : '';
const authorUrl = p.posterType === 'LOCATION'
? `/community/location-detail.html?id=${p.locationId || p.authorId}`
: `/community/benutzer.html?userId=${p.authorId}`;
return `<div class="post-card${clickableClass}" id="lp-${p.postId}"${onClickAttr}> return `<div class="post-card${clickableClass}" id="lp-${p.postId}"${onClickAttr}>
<div class="post-header"> <div class="post-header">
<div class="post-avatar">${avHtml}</div> <div class="post-avatar"><a href="${authorUrl}" onclick="event.stopPropagation()" style="display:contents;">${avHtml}</a></div>
<div> <div>
<div class="post-author">${escHtml(p.authorName || p.locationName || '')}</div> <div class="post-author"><a href="${authorUrl}" style="color:inherit;text-decoration:none;" onclick="event.stopPropagation()">${escHtml(p.authorName || p.locationName || '')}</a></div>
<div class="post-meta">${dateStr}${editedHtml}</div> <div class="post-meta">${dateStr}${editedHtml}</div>
</div> </div>
${adminBtns} ${adminBtns}
@@ -1401,11 +1420,15 @@ function openLpLb(postId) {
+ ' ' + new Date(p.createdAt).toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' }); + ' ' + 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 editedHtml = p.editedAt ? ' <span style="font-size:0.7rem;color:var(--color-muted);">(bearbeitet)</span>' : '';
const lbAuthorUrl = p.posterType === 'LOCATION'
? `/community/location-detail.html?id=${p.locationId || p.authorId}`
: `/community/benutzer.html?userId=${p.authorId}`;
document.getElementById('lbPostBody').innerHTML = ` document.getElementById('lbPostBody').innerHTML = `
<div class="post-header"> <div class="post-header">
<div class="post-avatar">${avHtml}</div> <div class="post-avatar"><a href="${lbAuthorUrl}" style="display:contents;">${avHtml}</a></div>
<div> <div>
<div class="post-author">${escHtml(p.authorName || p.locationName || '')}</div> <div class="post-author"><a href="${lbAuthorUrl}" style="color:inherit;text-decoration:none;">${escHtml(p.authorName || p.locationName || '')}</a></div>
<div class="post-meta">${dateStr}${editedHtml}</div> <div class="post-meta">${dateStr}${editedHtml}</div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,164 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Alle Events xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="/css/community.css">
<style>
.back-link { display:inline-flex; align-items:center; gap:0.35rem; color:var(--color-muted); font-size:0.88rem; text-decoration:none; margin-bottom:1rem; }
.back-link:hover { color:var(--color-primary); }
.page-title { font-size:1.15rem; font-weight:700; margin:0 0 1.25rem; }
.event-list { display:flex; flex-direction:column; gap:0.75rem; }
.event-card { background:var(--color-card); border:1px solid var(--color-secondary); border-radius:10px; display:flex; gap:0.75rem; padding:0.75rem; text-decoration:none; color:inherit; transition:border-color 0.15s; cursor:pointer; }
.event-card:hover { border-color:var(--color-primary); }
.event-card-img { width:64px; height:64px; border-radius:8px; object-fit:cover; background:var(--color-secondary); flex-shrink:0; overflow:hidden; display:flex; align-items:center; justify-content:center; font-size:1.4rem; }
.event-card-img img { width:100%; height:100%; object-fit:cover; }
.event-card-body { flex:1; min-width:0; }
.event-card-title { font-weight:600; font-size:0.92rem; margin:0 0 0.2rem; }
.event-card-date { font-size:0.78rem; color:var(--color-muted); }
.event-card-attendees { font-size:0.78rem; color:var(--color-muted); }
.paging-bar { display:flex; align-items:center; justify-content:center; gap:0.75rem; margin-top:1.25rem; flex-wrap:wrap; }
.paging-bar button { background:var(--color-secondary); border:none; color:var(--color-text); padding:0.45rem 1rem; border-radius:6px; font-size:0.88rem; cursor:pointer; transition:background 0.15s; }
.paging-bar button:hover:not(:disabled) { background:var(--color-primary); color:#fff; }
.paging-bar button:disabled { opacity:0.4; cursor:default; }
.paging-info { font-size:0.85rem; color:var(--color-muted); }
.empty-hint { color:var(--color-muted); font-size:0.9rem; }
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<a class="back-link" id="backLink" href="#"> Zurück zur Location</a>
<div id="locName" class="page-title">Alle Events</div>
<div id="eventList" class="event-list">
<p class="empty-hint">Wird geladen…</p>
</div>
<div class="paging-bar" id="pagingBar" style="display:none;">
<button id="prevBtn" onclick="changePage(-1)" disabled> Zurück</button>
<span class="paging-info" id="pagingInfo"></span>
<button id="nextBtn" onclick="changePage(1)">Weiter </button>
</div>
</div>
</div>
<script src="/js/nav.js"></script>
<script>
const PAGE_SIZE = 10;
const params = new URLSearchParams(location.search);
const locationId = params.get('id');
let allEvents = [];
let currentPage = 0;
document.getElementById('backLink').href = `/community/location-detail.html?id=${locationId}`;
function escHtml(s) {
if (!s) return '';
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function formatDate(iso) {
if (!iso) return '';
const d = new Date(iso);
return d.toLocaleDateString('de-DE', { weekday:'short', day:'2-digit', month:'2-digit', year:'numeric' })
+ ' ' + d.toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' }) + ' Uhr';
}
function buildEventCard(e) {
const imgHtml = e.imageData
? `<img src="data:image/jpeg;base64,${e.imageData}" alt="${escHtml(e.title)}">`
: '🗓';
return `
<a class="event-card" href="/community/event-detail.html?id=${e.eventId}">
<div class="event-card-img">${imgHtml}</div>
<div class="event-card-body">
<div class="event-card-title">${escHtml(e.title)}</div>
<div class="event-card-date">${formatDate(e.startAt)}</div>
<div class="event-card-attendees">${e.attendeeCount} Teilnehmer*in(nen)${e.attendingMe ? ' · Du nimmst teil' : ''}</div>
</div>
</a>`;
}
function renderPage() {
const list = document.getElementById('eventList');
const totalPages = Math.ceil(allEvents.length / PAGE_SIZE);
const start = currentPage * PAGE_SIZE;
const slice = allEvents.slice(start, start + PAGE_SIZE);
list.innerHTML = slice.length
? slice.map(buildEventCard).join('')
: '<p class="empty-hint">Keine weiteren Veranstaltungen.</p>';
const pagingBar = document.getElementById('pagingBar');
if (allEvents.length > PAGE_SIZE) {
pagingBar.style.display = '';
document.getElementById('prevBtn').disabled = currentPage === 0;
document.getElementById('nextBtn').disabled = currentPage >= totalPages - 1;
document.getElementById('pagingInfo').textContent =
`Seite ${currentPage + 1} von ${totalPages} (${allEvents.length} Events)`;
} else {
pagingBar.style.display = 'none';
}
}
function changePage(dir) {
const totalPages = Math.ceil(allEvents.length / PAGE_SIZE);
const next = currentPage + dir;
if (next < 0 || next >= totalPages) return;
currentPage = next;
renderPage();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
async function init() {
if (!locationId) {
document.getElementById('eventList').innerHTML = '<p class="empty-hint">Keine Location-ID angegeben.</p>';
return;
}
// Locationname laden
const locRes = await fetch(`/locations/${locationId}`);
if (locRes.ok) {
const loc = await locRes.json();
document.getElementById('locName').textContent = `Alle Events ${loc.name}`;
document.title = `Events ${loc.name} xXx Sphere`;
}
// Events laden
const res = await fetch(`/locations/${locationId}/events`);
if (!res.ok) {
document.getElementById('eventList').innerHTML = '<p class="empty-hint">Events konnten nicht geladen werden.</p>';
return;
}
const events = await res.json();
const now = new Date();
allEvents = events
.filter(e => new Date(e.startAt) >= now)
.sort((a, b) => new Date(a.startAt) - new Date(b.startAt));
if (allEvents.length === 0) {
document.getElementById('eventList').innerHTML = '<p class="empty-hint">Keine bevorstehenden Veranstaltungen.</p>';
return;
}
renderPage();
}
init();
</script>
</body>
</html>

View File

@@ -95,6 +95,7 @@
} }
.gruppe-badge-private { background: rgba(233,69,96,0.15); color: var(--color-primary); } .gruppe-badge-private { background: rgba(233,69,96,0.15); color: var(--color-primary); }
.gruppe-badge-public { background: rgba(46,204,113,0.15); color: var(--color-success); } .gruppe-badge-public { background: rgba(46,204,113,0.15); color: var(--color-success); }
.gruppe-badge-vanilla { background: #e8f5e9; color: #2e7d32; border: 1px solid #a5d6a7; }
.gruppe-toggle { font-size: 0.75rem; color: var(--color-muted); flex-shrink: 0; transition: transform 0.2s; } .gruppe-toggle { font-size: 0.75rem; color: var(--color-muted); flex-shrink: 0; transition: transform 0.2s; }
.gruppe-card.open .gruppe-toggle { transform: rotate(90deg); } .gruppe-card.open .gruppe-toggle { transform: rotate(90deg); }
@@ -367,6 +368,12 @@
Gruppe veröffentlichen (für alle sichtbar) Gruppe veröffentlichen (für alle sichtbar)
</span> </span>
</label> </label>
<label>
<span class="modal-check">
<input type="checkbox" id="gVanilla">
Auch für Vanilla-Game verfügbar
</span>
</label>
<div class="modal-error" id="modalError"></div> <div class="modal-error" id="modalError"></div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn-cancel" id="cancelBtn">Abbrechen</button> <button class="btn-cancel" id="cancelBtn">Abbrechen</button>
@@ -715,6 +722,7 @@
if (g.privateGruppe) badges.push(`<span class="gruppe-badge gruppe-badge-private">Privat</span>`); if (g.privateGruppe) badges.push(`<span class="gruppe-badge gruppe-badge-private">Privat</span>`);
else badges.push(`<span class="gruppe-badge gruppe-badge-public">Öffentlich</span>`); else badges.push(`<span class="gruppe-badge gruppe-badge-public">Öffentlich</span>`);
if (type === 'user' && g.subscriberCount > 0) badges.push(`<span class="gruppe-badge">♥ ${g.subscriberCount} Abo${g.subscriberCount !== 1 ? 's' : ''}</span>`); if (type === 'user' && g.subscriberCount > 0) badges.push(`<span class="gruppe-badge">♥ ${g.subscriberCount} Abo${g.subscriberCount !== 1 ? 's' : ''}</span>`);
if (g.vanillaAvailable) badges.push(`<span class="gruppe-badge gruppe-badge-vanilla">Vanilla</span>`);
return ` return `
<div class="gruppe-card" id="gruppe-${esc(g.gruppenId)}"> <div class="gruppe-card" id="gruppe-${esc(g.gruppenId)}">
@@ -1069,6 +1077,7 @@
pubCb.checked = !g.privateGruppe; pubCb.checked = !g.privateGruppe;
pubCb.disabled = g.privateGruppe; // Veröffentlichen nur über den Veröffentlichen-Button pubCb.disabled = g.privateGruppe; // Veröffentlichen nur über den Veröffentlichen-Button
document.getElementById('gPublicLabel').style.display = 'block'; document.getElementById('gPublicLabel').style.display = 'block';
document.getElementById('gVanilla').checked = g.vanillaAvailable || false;
const imgWrap = document.getElementById('gCurrentImgWrap'); const imgWrap = document.getElementById('gCurrentImgWrap');
if (g.bild) { if (g.bild) {
document.getElementById('gCurrentImg').src = 'data:image/png;base64,' + g.bild; document.getElementById('gCurrentImg').src = 'data:image/png;base64,' + g.bild;
@@ -1086,6 +1095,7 @@
document.getElementById('gDesc').value = ''; document.getElementById('gDesc').value = '';
document.getElementById('gPublic').checked = false; document.getElementById('gPublic').checked = false;
document.getElementById('gPublicLabel').style.display = 'none'; document.getElementById('gPublicLabel').style.display = 'none';
document.getElementById('gVanilla').checked = false;
document.getElementById('gCurrentImgWrap').style.display = 'none'; document.getElementById('gCurrentImgWrap').style.display = 'none';
gruppeModal.classList.add('open'); gruppeModal.classList.add('open');
document.getElementById('gName').focus(); document.getElementById('gName').focus();
@@ -1119,6 +1129,7 @@
name, name,
beschreibung: document.getElementById('gDesc').value.trim() || null, beschreibung: document.getElementById('gDesc').value.trim() || null,
privateGruppe: isEdit ? !document.getElementById('gPublic').checked : true, privateGruppe: isEdit ? !document.getElementById('gPublic').checked : true,
vanillaAvailable: document.getElementById('gVanilla').checked,
bild: bildBase64 bild: bildBase64
}; };

View File

@@ -97,6 +97,7 @@
.item-img { width: 38px; height: 38px; object-fit: cover; border-radius: 6px; flex-shrink: 0; } .item-img { width: 38px; height: 38px; object-fit: cover; border-radius: 6px; flex-shrink: 0; }
.gruppe-item-name, .toy-item-name { font-size: 0.95rem; font-weight: 600; color: var(--color-text); } .gruppe-item-name, .toy-item-name { font-size: 0.95rem; font-weight: 600; color: var(--color-text); }
.gruppe-item-desc, .toy-item-desc { display: block; font-size: 0.8rem; color: var(--color-muted); margin-top: 0.15rem; } .gruppe-item-desc, .toy-item-desc { display: block; font-size: 0.8rem; color: var(--color-muted); margin-top: 0.15rem; }
.vanilla-badge { display: inline-block; font-size: 0.65rem; font-weight: 700; padding: 0.1rem 0.35rem; border-radius: 3px; background: #e8f5e9; color: #2e7d32; border: 1px solid #a5d6a7; margin-left: 0.35rem; vertical-align: middle; letter-spacing: 0.03em; }
.empty-hint { color: var(--color-muted); font-size: 0.875rem; font-style: italic; padding: 0.5rem 0; } .empty-hint { color: var(--color-muted); font-size: 0.875rem; font-style: italic; padding: 0.5rem 0; }
.aufgaben-section-label { font-size: 0.78rem; font-weight: 600; color: var(--color-muted); text-transform: uppercase; letter-spacing: 0.04em; margin: 1rem 0 0.5rem 0; } .aufgaben-section-label { font-size: 0.78rem; font-weight: 600; color: var(--color-muted); text-transform: uppercase; letter-spacing: 0.04em; margin: 1rem 0 0.5rem 0; }
.aufgaben-section-label:first-child { margin-top: 0; } .aufgaben-section-label:first-child { margin-top: 0; }
@@ -653,9 +654,10 @@
} }
ul.innerHTML = gruppen.map(g => { ul.innerHTML = gruppen.map(g => {
const checked = savedGruppen.has(g.gruppenId); const checked = savedGruppen.has(g.gruppenId);
const vanillaBadge = g.vanillaAvailable ? '<span class="vanilla-badge">Vanilla</span>' : '';
return `<li><label class="gruppe-item${checked ? ' is-checked' : ''}"> return `<li><label class="gruppe-item${checked ? ' is-checked' : ''}">
<input type="checkbox" value="${g.gruppenId}"${checked ? ' checked' : ''}> <input type="checkbox" value="${g.gruppenId}"${checked ? ' checked' : ''}>
<span><span class="gruppe-item-name">${g.name}</span>${g.beschreibung ? `<span class="gruppe-item-desc">${g.beschreibung}</span>` : ''}</span> <span><span class="gruppe-item-name">${g.name}${vanillaBadge}</span>${g.beschreibung ? `<span class="gruppe-item-desc">${g.beschreibung}</span>` : ''}</span>
${g.bild ? `<img class="item-img" src="data:image/png;base64,${g.bild}" alt="">` : ''} ${g.bild ? `<img class="item-img" src="data:image/png;base64,${g.bild}" alt="">` : ''}
</label></li>`; </label></li>`;
}).join(''); }).join('');

View File

@@ -887,11 +887,14 @@
</div>` </div>`
: ''; : '';
const gruppeIdAttr = p.gruppeId ? ` data-gruppe-id="${p.gruppeId}"` : ''; const gruppeIdAttr = p.gruppeId ? ` data-gruppe-id="${p.gruppeId}"` : '';
const authorUrl = p.posterType === 'LOCATION'
? `/community/location-detail.html?id=${p.locationId || p.authorId}`
: `/community/benutzer.html?userId=${p.authorId}`;
return `<div class="post-card" id="hpc-${p.postId}"${gruppeIdAttr} onclick="homeOpenPost('${p.postId}')"> 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"><a href="${authorUrl}" 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="${authorUrl}" style="color:inherit;text-decoration:none;" onclick="event.stopPropagation()">${esc(p.authorName)}</a>${privacyLabel}</div>
<div class="post-meta" id="hpm-${p.postId}">${fmtDate(p.createdAt)}${editedLabel}${groupBadge}</div> <div class="post-meta" id="hpm-${p.postId}">${fmtDate(p.createdAt)}${editedLabel}${groupBadge}</div>
</div> </div>
${ownBtns} ${ownBtns}