Aufgabenverwaltung angepasst, Eventseite weiter bearbeitet
Some checks failed
Host-Based Deploy (Java 21 Fix) / build-and-run (push) Has been cancelled
Some checks failed
Host-Based Deploy (Java 21 Fix) / build-and-run (push) Has been cancelled
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -120,7 +120,8 @@
|
||||
.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-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-card.open .gruppe-toggle { transform:rotate(90deg); }
|
||||
.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>
|
||||
</div>
|
||||
<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-actions">
|
||||
<button class="btn-cancel" id="gruppeModalCancel">Abbrechen</button>
|
||||
@@ -986,7 +993,7 @@ function renderAdminGruppen(gruppen) {
|
||||
<div class="gruppe-meta">
|
||||
<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-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>
|
||||
<span class="gruppe-toggle">▶</span>
|
||||
</div>
|
||||
@@ -1262,6 +1269,7 @@ function openGruppeModal(editId) {
|
||||
document.getElementById('gName').value = g.name || '';
|
||||
document.getElementById('gVon').value = g.von || '';
|
||||
document.getElementById('gDesc').value = g.beschreibung || '';
|
||||
document.getElementById('gVanilla').checked = g.vanillaAvailable || false;
|
||||
const imgWrap = document.getElementById('gCurrentImgWrap');
|
||||
if (g.bild) { document.getElementById('gCurrentImg').src = 'data:image/png;base64,' + g.bild; imgWrap.style.display = 'flex'; }
|
||||
else imgWrap.style.display = 'none';
|
||||
@@ -1270,6 +1278,7 @@ function openGruppeModal(editId) {
|
||||
document.getElementById('gName').value = '';
|
||||
document.getElementById('gVon').value = '';
|
||||
document.getElementById('gDesc').value = '';
|
||||
document.getElementById('gVanilla').checked = false;
|
||||
document.getElementById('gCurrentImgWrap').style.display = 'none';
|
||||
}
|
||||
gruppeModal.classList.add('open');
|
||||
@@ -1348,7 +1357,7 @@ gruppeModalSave.addEventListener('click', async () => {
|
||||
let bildBase64 = null;
|
||||
const fi = document.getElementById('gBild');
|
||||
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;
|
||||
fetch(isEdit ? `/admin/aufgabengruppen/${currentEditGruppeId}` : '/admin/aufgabengruppen', {
|
||||
method: isEdit ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload)
|
||||
|
||||
@@ -195,6 +195,7 @@ function renderPage(evt) {
|
||||
|
||||
const totalAttendees = (evt.attendees || []).length;
|
||||
const attending = evt.attendingMe;
|
||||
const isPast = new Date(evt.startAt) < new Date();
|
||||
|
||||
document.getElementById('content').innerHTML = `
|
||||
<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>
|
||||
</div>` : ''}
|
||||
<div style="display:flex;align-items:center;gap:0.4rem;flex-wrap:wrap;">
|
||||
<button class="btn" id="attendBtn"
|
||||
style="${attending ? 'background:var(--color-secondary);color:var(--color-text);' : ''}"
|
||||
onclick="toggleAttend()">
|
||||
${attending ? '✓ Ich bin dabei' : '+ Ich bin dabei'}
|
||||
</button>
|
||||
${isPast
|
||||
? `<span style="color:var(--color-muted);font-size:0.85rem;">Veranstaltung bereits beendet</span>`
|
||||
: `<button class="btn" id="attendBtn"
|
||||
style="${attending ? 'background:var(--color-secondary);color:var(--color-text);' : ''}"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -445,6 +445,9 @@ function renderPage() {
|
||||
${isOwner ? `<button class="btn" style="font-size:0.8rem;" onclick="openEventModal()">+ Veranstaltung erstellen</button>` : ''}
|
||||
</div>
|
||||
<div class="event-list" id="eventList"><p style="color:var(--color-muted);font-size:0.9rem;">Wird geladen…</p></div>
|
||||
<div id="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 class="section-title" style="margin-top:1.5rem;">Vergangene Veranstaltungen</div>
|
||||
<div class="event-list" id="pastEventList"></div>
|
||||
@@ -517,8 +520,8 @@ function renderPage() {
|
||||
${locHeaderHtml}
|
||||
${hoursHtml}
|
||||
${gallerySection}
|
||||
${feedSection}
|
||||
${eventsSection}`;
|
||||
${eventsSection}
|
||||
${feedSection}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -587,15 +590,27 @@ async function loadEvents() {
|
||||
if (!list) return;
|
||||
|
||||
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)
|
||||
.slice(-5) // letzte 5
|
||||
.slice(-3) // letzte 3
|
||||
.reverse(); // neueste zuerst
|
||||
|
||||
list.innerHTML = future.length
|
||||
? future.map(e => buildEventCard(e, false)).join('')
|
||||
const preview = future.slice(0, 3);
|
||||
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>';
|
||||
|
||||
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');
|
||||
if (past.length && pastSection) {
|
||||
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>
|
||||
</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}>
|
||||
<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 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>
|
||||
${adminBtns}
|
||||
@@ -1401,11 +1420,15 @@ function openLpLb(postId) {
|
||||
+ ' ' + 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 lbAuthorUrl = p.posterType === 'LOCATION'
|
||||
? `/community/location-detail.html?id=${p.locationId || p.authorId}`
|
||||
: `/community/benutzer.html?userId=${p.authorId}`;
|
||||
|
||||
document.getElementById('lbPostBody').innerHTML = `
|
||||
<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 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>
|
||||
</div>
|
||||
|
||||
164
bin/main/static/community/location-events.html
Normal file
164
bin/main/static/community/location-events.html
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -95,6 +95,7 @@
|
||||
}
|
||||
.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-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-card.open .gruppe-toggle { transform: rotate(90deg); }
|
||||
|
||||
@@ -367,6 +368,12 @@
|
||||
Gruppe veröffentlichen (für alle sichtbar)
|
||||
</span>
|
||||
</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-actions">
|
||||
<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>`);
|
||||
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 (g.vanillaAvailable) badges.push(`<span class="gruppe-badge gruppe-badge-vanilla">Vanilla</span>`);
|
||||
|
||||
return `
|
||||
<div class="gruppe-card" id="gruppe-${esc(g.gruppenId)}">
|
||||
@@ -1069,6 +1077,7 @@
|
||||
pubCb.checked = !g.privateGruppe;
|
||||
pubCb.disabled = g.privateGruppe; // Veröffentlichen nur über den Veröffentlichen-Button
|
||||
document.getElementById('gPublicLabel').style.display = 'block';
|
||||
document.getElementById('gVanilla').checked = g.vanillaAvailable || false;
|
||||
const imgWrap = document.getElementById('gCurrentImgWrap');
|
||||
if (g.bild) {
|
||||
document.getElementById('gCurrentImg').src = 'data:image/png;base64,' + g.bild;
|
||||
@@ -1086,6 +1095,7 @@
|
||||
document.getElementById('gDesc').value = '';
|
||||
document.getElementById('gPublic').checked = false;
|
||||
document.getElementById('gPublicLabel').style.display = 'none';
|
||||
document.getElementById('gVanilla').checked = false;
|
||||
document.getElementById('gCurrentImgWrap').style.display = 'none';
|
||||
gruppeModal.classList.add('open');
|
||||
document.getElementById('gName').focus();
|
||||
@@ -1119,6 +1129,7 @@
|
||||
name,
|
||||
beschreibung: document.getElementById('gDesc').value.trim() || null,
|
||||
privateGruppe: isEdit ? !document.getElementById('gPublic').checked : true,
|
||||
vanillaAvailable: document.getElementById('gVanilla').checked,
|
||||
bild: bildBase64
|
||||
};
|
||||
|
||||
|
||||
@@ -97,6 +97,7 @@
|
||||
.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-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; }
|
||||
.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; }
|
||||
@@ -653,9 +654,10 @@
|
||||
}
|
||||
ul.innerHTML = gruppen.map(g => {
|
||||
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' : ''}">
|
||||
<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="">` : ''}
|
||||
</label></li>`;
|
||||
}).join('');
|
||||
|
||||
@@ -887,11 +887,14 @@
|
||||
</div>`
|
||||
: '';
|
||||
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}')">
|
||||
<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 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>
|
||||
${ownBtns}
|
||||
|
||||
@@ -203,8 +203,10 @@ public class AdminController {
|
||||
@GetMapping("/aufgabengruppen")
|
||||
public ResponseEntity<List<AufgabenGruppe>> getAufgabengruppen(Principal principal) {
|
||||
requireAdmin(principal);
|
||||
List<AufgabenGruppeEntity> list = aufgabenGruppeRepository
|
||||
.findByUserIdIsNull(PageRequest.of(0, 1000)).getContent();
|
||||
List<AufgabenGruppeEntity> list = aufgabenGruppeRepository.findAll().stream()
|
||||
.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());
|
||||
}
|
||||
|
||||
@@ -230,9 +232,11 @@ public class AdminController {
|
||||
entity.setName(gruppe.getName());
|
||||
entity.setBeschreibung(gruppe.getBeschreibung());
|
||||
entity.setVon(gruppe.getVon());
|
||||
entity.setVanillaAvailable(gruppe.isVanillaAvailable());
|
||||
if (gruppe.getBild() != null) {
|
||||
entity.setBild(java.util.Base64.getDecoder().decode(gruppe.getBild()));
|
||||
}
|
||||
aufgabenGruppeRepository.save(entity);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,16 @@ public class SecurityConfig {
|
||||
http
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.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
|
||||
.authenticationEntryPoint((request, response, authException) ->
|
||||
response.sendRedirect("/login.html")))
|
||||
@@ -77,6 +87,7 @@ public class SecurityConfig {
|
||||
.requestMatchers("/dating/matches.html").authenticated()
|
||||
.requestMatchers("/community/locations.html").authenticated()
|
||||
.requestMatchers("/community/location-detail.html").authenticated()
|
||||
.requestMatchers("/community/location-events.html").authenticated()
|
||||
.requestMatchers("/community/events.html").authenticated()
|
||||
.requestMatchers("/community/event-detail.html").authenticated()
|
||||
.requestMatchers("/gruppen/**").authenticated()
|
||||
|
||||
@@ -4,12 +4,17 @@ import de.oaa.xxx.mail.Email;
|
||||
import de.oaa.xxx.mail.MailService;
|
||||
import de.oaa.xxx.support.SupportUserService;
|
||||
import de.oaa.xxx.user.UserRepository;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.security.Principal;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/feedback")
|
||||
@@ -20,6 +25,10 @@ public class FeedbackController {
|
||||
private final UserRepository userRepository;
|
||||
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,
|
||||
FeedbackRepository feedbackRepository,
|
||||
UserRepository userRepository,
|
||||
@@ -33,11 +42,29 @@ public class FeedbackController {
|
||||
record FeedbackRequest(String name, String seite, String grund, String text) {}
|
||||
|
||||
@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) {
|
||||
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)
|
||||
UUID userId = null;
|
||||
if (principal != null) {
|
||||
@@ -86,4 +113,8 @@ public class FeedbackController {
|
||||
if (s == null) return "";
|
||||
return s.replace("&", "&").replace("<", "<").replace(">", ">");
|
||||
}
|
||||
|
||||
private boolean containsLineBreak(String s) {
|
||||
return s != null && (s.contains("\n") || s.contains("\r"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +79,8 @@ public class AboController {
|
||||
String namePattern = name != null && !name.isBlank() ? "%" + name.trim() + "%" : null;
|
||||
|
||||
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)))
|
||||
.sorted(Comparator.comparingLong(AufgabenGruppe::getSubscriberCount).reversed()
|
||||
.thenComparing(AufgabenGruppe::getName, String.CASE_INSENSITIVE_ORDER))
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
package de.oaa.xxx.games.bdsm.controller;
|
||||
|
||||
import java.security.Principal;
|
||||
import java.util.Base64;
|
||||
import java.util.UUID;
|
||||
|
||||
import de.oaa.xxx.games.common.aufgaben.AufgabenGruppe;
|
||||
import de.oaa.xxx.games.common.aufgaben.AufgabenGruppeList;
|
||||
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.LoggerFactory;
|
||||
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.servlet.support.ServletUriComponentsBuilder;
|
||||
|
||||
import de.oaa.xxx.games.common.aufgaben.AufgabenGruppe;
|
||||
import de.oaa.xxx.games.common.aufgaben.AufgabenGruppeList;
|
||||
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 java.security.Principal;
|
||||
import java.util.Base64;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/gruppe")
|
||||
@@ -75,7 +74,7 @@ public class AufgabenGruppeController {
|
||||
this.userService = userService;
|
||||
}
|
||||
|
||||
// ── Paginierte Listen ──
|
||||
// ── Paginierte Listen (alle Gruppen – BDSM-Verwaltung und Spielstart) ──
|
||||
|
||||
@GetMapping("/list/user")
|
||||
public ResponseEntity<AufgabenGruppePage> listUser(
|
||||
@@ -105,7 +104,7 @@ public class AufgabenGruppeController {
|
||||
UUID userId = userService.requireUser(principal).getUserId();
|
||||
String searchPattern = search != null ? "%" + search + "%" : null;
|
||||
AufgabenGruppeList list = new AufgabenGruppeList();
|
||||
list.setGruppen(gruppeRepository.listWithUserAndSearch(userId, searchPattern, PageRequest.of(0, 500))
|
||||
list.setGruppen(gruppeRepository.listAllWithUserAndSearch(userId, searchPattern, PageRequest.of(0, 500))
|
||||
.stream().map(AufgabenGruppeEntity::toAufgabenGruppeDisplay).toList());
|
||||
return ResponseEntity.ok(list);
|
||||
}
|
||||
@@ -118,6 +117,7 @@ public class AufgabenGruppeController {
|
||||
return ResponseEntity.ok(list);
|
||||
}
|
||||
|
||||
/** Gibt eine einzelne Gruppe zurück – typunabhängig, da BDSM-Spielstart auch vanillaAvailable-Gruppen laden muss. */
|
||||
@GetMapping("/{gruppeId}")
|
||||
public ResponseEntity<AufgabenGruppe> get(@PathVariable("gruppeId") UUID gruppeId) {
|
||||
return gruppeRepository.findById(gruppeId)
|
||||
@@ -142,8 +142,9 @@ public class AufgabenGruppeController {
|
||||
AufgabenGruppeEntity entity = AufgabenGruppeEntity.create(gruppe);
|
||||
entity.setUserId(user.getUserId());
|
||||
entity.setPrivateGruppe(true);
|
||||
// vanillaAvailable kommt aus dem Request-Body (Checkbox im Frontend)
|
||||
gruppeRepository.save(entity);
|
||||
LOGGER.debug("User {} hat AufgabenGruppe '{}' ({}) erstellt", user.getUserId(), entity.getName(), entity.getGruppenId());
|
||||
LOGGER.debug("User {} hat BDSM-AufgabenGruppe '{}' ({}) erstellt", user.getUserId(), entity.getName(), entity.getGruppenId());
|
||||
return ResponseEntity.created(
|
||||
ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}").buildAndExpand(entity.getGruppenId()).toUri()
|
||||
).build();
|
||||
@@ -169,11 +170,12 @@ public class AufgabenGruppeController {
|
||||
entity.setBeschreibung(gruppe.getBeschreibung());
|
||||
entity.setVon(gruppe.getVon());
|
||||
entity.setPrivateGruppe(gruppe.isPrivateGruppe());
|
||||
entity.setVanillaAvailable(gruppe.isVanillaAvailable());
|
||||
if (gruppe.getBild() != null) {
|
||||
entity.setBild(Base64.getDecoder().decode(gruppe.getBild()));
|
||||
}
|
||||
gruppeRepository.save(entity);
|
||||
LOGGER.debug("User {} hat AufgabenGruppe {} aktualisiert", user.getUserId(), gruppeId);
|
||||
LOGGER.debug("User {} hat BDSM-AufgabenGruppe {} aktualisiert", user.getUserId(), gruppeId);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
|
||||
@@ -23,4 +23,5 @@ public class AufgabenGruppe {
|
||||
private String bild;
|
||||
private long subscriberCount;
|
||||
private boolean subscribed;
|
||||
private boolean vanillaAvailable;
|
||||
}
|
||||
|
||||
@@ -16,4 +16,5 @@ public class AufgabenGruppeDisplay {
|
||||
private boolean privateGruppe;
|
||||
private String bild;
|
||||
private String von;
|
||||
private boolean vanillaAvailable;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ public class AufgabenGruppeEntity {
|
||||
private byte[] bild;
|
||||
@Column
|
||||
private String von;
|
||||
@Column(columnDefinition = "BOOLEAN DEFAULT FALSE NOT NULL")
|
||||
private boolean vanillaAvailable = false;
|
||||
@OneToMany(mappedBy = "aufgabenGruppe")
|
||||
private List<AufgabeEntity> aufgaben;
|
||||
@OneToMany(mappedBy = "aufgabenGruppe")
|
||||
@@ -62,6 +64,7 @@ public class AufgabenGruppeEntity {
|
||||
gruppe.setPrivateGruppe(privateGruppe);
|
||||
gruppe.setBild(bild != null ? Base64.getEncoder().encodeToString(bild) : null);
|
||||
gruppe.setVon(von);
|
||||
gruppe.setVanillaAvailable(vanillaAvailable);
|
||||
gruppe.setAufgaben(aufgaben.stream().map(AufgabeEntity::toAufgabe).toList());
|
||||
gruppe.setStrafen(strafen.stream().map(StrafeEntity::toStrafe).toList());
|
||||
gruppe.setSperren(sperren.stream().map(SperreEntity::toSperre).toList());
|
||||
@@ -78,6 +81,7 @@ public class AufgabenGruppeEntity {
|
||||
entity.setPrivateGruppe(gruppe.isPrivateGruppe());
|
||||
entity.setBild(gruppe.getBild() != null ? Base64.getDecoder().decode(gruppe.getBild()) : null);
|
||||
entity.setVon(gruppe.getVon());
|
||||
entity.setVanillaAvailable(gruppe.isVanillaAvailable());
|
||||
return entity;
|
||||
}
|
||||
|
||||
@@ -90,6 +94,7 @@ public class AufgabenGruppeEntity {
|
||||
display.setPrivateGruppe(privateGruppe);
|
||||
display.setBild(bild != null ? Base64.getEncoder().encodeToString(bild) : null);
|
||||
display.setVon(von);
|
||||
display.setVanillaAvailable(vanillaAvailable);
|
||||
return display;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,47 +1,46 @@
|
||||
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.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import de.oaa.xxx.games.common.entity.AufgabenGruppeEntity;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.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);
|
||||
|
||||
long countByUserId(UUID userId);
|
||||
|
||||
Page<AufgabenGruppeEntity> findByUserIdIsNull(Pageable pageable);
|
||||
// ── BDSM-Verwaltung: alle Gruppen paginiert ───────────────────────────────
|
||||
|
||||
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)")
|
||||
List<AufgabenGruppeEntity> listWithUserAndSearch(@Param("userId") UUID userId, @Param("search") String search, PageRequest pageable);
|
||||
Page<AufgabenGruppeEntity> findByUserIdIsNull(Pageable pageable);
|
||||
|
||||
@Query("select age from AufgabenGruppeEntity age where age.privateGruppe = false and (:search is null or age.name like :search)")
|
||||
List<AufgabenGruppeEntity> listPublicWithSearch(@Param("search") String search, PageRequest pageable);
|
||||
// ── Vanilla-Verwaltung: nur vanillaAvailable=true, mit Inhalt ─────────────
|
||||
|
||||
@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))")
|
||||
List<AufgabenGruppeEntity> findPublicFromOthers(@Param("userId") UUID userId, @Param("name") String 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)")
|
||||
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)")
|
||||
List<AufgabenGruppeEntity> listVanillaSafeWithUserAndSearch(@Param("userId") UUID userId, @Param("search") String search, PageRequest pageable);
|
||||
@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)")
|
||||
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))")
|
||||
List<AufgabenGruppeEntity> findVanillaSafePublicFromOthers(@Param("userId") UUID userId, @Param("name") String name);
|
||||
// ── Spielstart-Auswahl ────────────────────────────────────────────────────
|
||||
|
||||
@Query("SELECT g FROM AufgabenGruppeEntity g WHERE g.userId = :userId AND (g.aufgaben IS NOT EMPTY OR g.finisher IS NOT EMPTY)")
|
||||
Page<AufgabenGruppeEntity> findByUserIdWithContent(@Param("userId") UUID userId, Pageable pageable);
|
||||
/** Nur vanillaAvailable-Gruppen – für Vanilla-Spielstart und Vanilla-Suche. */
|
||||
@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)")
|
||||
Page<AufgabenGruppeEntity> findSystemGroupsWithContent(Pageable pageable);
|
||||
/** Alle Gruppen – für BDSM-Spielstart (vanillaAvailable-Gruppen werden im Frontend hervorgehoben). */
|
||||
@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);
|
||||
}
|
||||
|
||||
@@ -6,9 +6,7 @@ import java.util.UUID;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
@@ -58,16 +56,21 @@ public class VanillaAboController {
|
||||
Principal principal) {
|
||||
UserEntity user = userService.requireUser(principal);
|
||||
|
||||
Page<GruppenAboEntity> dbPage = aboRepository.findByUserIdWithContent(
|
||||
user.getUserId(), PageRequest.of(page, size, Sort.by("aufgabenGruppe.name")));
|
||||
List<AufgabenGruppe> dtos = dbPage.getContent().stream()
|
||||
.map(a -> enrich(a.getAufgabenGruppe(), user.getUserId(), true))
|
||||
List<AufgabenGruppe> all = aboRepository.findByUserId(user.getUserId()).stream()
|
||||
.map(GruppenAboEntity::getAufgabenGruppe)
|
||||
.filter(g -> g.isVanillaAvailable()
|
||||
&& !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();
|
||||
int total = all.size();
|
||||
int start = page * size;
|
||||
AufgabenGruppePage result = new AufgabenGruppePage();
|
||||
result.setContent(dtos);
|
||||
result.setCurrentPage(dbPage.getNumber());
|
||||
result.setTotalPages(dbPage.getTotalPages());
|
||||
result.setTotalElements(dbPage.getTotalElements());
|
||||
result.setContent(start >= total ? List.of() : all.subList(start, Math.min(start + size, total)));
|
||||
result.setCurrentPage(page);
|
||||
result.setTotalPages(total == 0 ? 1 : (int) Math.ceil((double) total / size));
|
||||
result.setTotalElements(total);
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
@@ -84,7 +87,8 @@ public class VanillaAboController {
|
||||
String namePattern = name != null && !name.isBlank() ? "%" + name.trim() + "%" : null;
|
||||
|
||||
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)))
|
||||
.sorted(java.util.Comparator.comparingLong(AufgabenGruppe::getSubscriberCount).reversed()
|
||||
.thenComparing(AufgabenGruppe::getName, String.CASE_INSENSITIVE_ORDER))
|
||||
@@ -111,8 +115,7 @@ public class VanillaAboController {
|
||||
if (gruppe == null || gruppe.isPrivateGruppe() || user.getUserId().equals(gruppe.getUserId())) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
// Vanilla-safe validation
|
||||
if (!gruppe.getStrafen().isEmpty() || !gruppe.getSperren().isEmpty()) {
|
||||
if (!gruppe.isVanillaAvailable()) {
|
||||
return ResponseEntity.status(403).build();
|
||||
}
|
||||
if (aboRepository.existsByUserIdAndAufgabenGruppe(user.getUserId(), gruppe)) {
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
package de.oaa.xxx.games.vanilla.controller;
|
||||
|
||||
import java.security.Principal;
|
||||
import java.util.Base64;
|
||||
import java.util.UUID;
|
||||
|
||||
import de.oaa.xxx.games.common.aufgaben.AufgabenGruppe;
|
||||
import de.oaa.xxx.games.common.aufgaben.AufgabenGruppeList;
|
||||
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.LoggerFactory;
|
||||
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.servlet.support.ServletUriComponentsBuilder;
|
||||
|
||||
import de.oaa.xxx.games.common.aufgaben.AufgabenGruppe;
|
||||
import de.oaa.xxx.games.common.aufgaben.AufgabenGruppeList;
|
||||
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 java.security.Principal;
|
||||
import java.util.Base64;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/vanilla/gruppe")
|
||||
@@ -84,7 +83,7 @@ public class VanillaAufgabenGruppeController {
|
||||
Principal principal) {
|
||||
UserEntity user = resolveUser(principal);
|
||||
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")));
|
||||
AufgabenGruppePage result = new AufgabenGruppePage();
|
||||
result.setContent(dbPage.getContent().stream().map(entity -> {
|
||||
@@ -102,11 +101,10 @@ public class VanillaAufgabenGruppeController {
|
||||
public ResponseEntity<AufgabenGruppePage> listSystem(
|
||||
@RequestParam(name = "page", defaultValue = "0") int page,
|
||||
@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")));
|
||||
AufgabenGruppePage r = new AufgabenGruppePage();
|
||||
r.setContent(dbPage.getContent().stream()
|
||||
.map(AufgabenGruppeEntity::toAufgabenGruppe).toList());
|
||||
r.setContent(dbPage.getContent().stream().map(AufgabenGruppeEntity::toAufgabenGruppe).toList());
|
||||
r.setCurrentPage(dbPage.getNumber());
|
||||
r.setTotalPages(dbPage.getTotalPages());
|
||||
r.setTotalElements(dbPage.getTotalElements());
|
||||
@@ -120,7 +118,8 @@ public class VanillaAufgabenGruppeController {
|
||||
UUID userId = userService.requireUser(principal).getUserId();
|
||||
String searchPattern = search != null ? "%" + search + "%" : null;
|
||||
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());
|
||||
return ResponseEntity.ok(list);
|
||||
}
|
||||
@@ -129,7 +128,7 @@ public class VanillaAufgabenGruppeController {
|
||||
public ResponseEntity<AufgabenGruppeList> getOwn(@RequestParam("userId") UUID userId) {
|
||||
AufgabenGruppeList list = new AufgabenGruppeList();
|
||||
list.setGruppen(gruppeRepository.findByUserId(userId).stream()
|
||||
.filter(g -> g.getStrafen().isEmpty() && g.getSperren().isEmpty())
|
||||
.filter(AufgabenGruppeEntity::isVanillaAvailable)
|
||||
.map(AufgabenGruppeEntity::toAufgabenGruppeDisplay).toList());
|
||||
return ResponseEntity.ok(list);
|
||||
}
|
||||
@@ -137,7 +136,7 @@ public class VanillaAufgabenGruppeController {
|
||||
@GetMapping("/{gruppeId}")
|
||||
public ResponseEntity<AufgabenGruppe> get(@PathVariable("gruppeId") UUID gruppeId) {
|
||||
return gruppeRepository.findById(gruppeId)
|
||||
.filter(g -> g.getStrafen().isEmpty() && g.getSperren().isEmpty())
|
||||
.filter(AufgabenGruppeEntity::isVanillaAvailable)
|
||||
.map(entity -> ResponseEntity.ok(entity.toAufgabenGruppe()))
|
||||
.orElse(ResponseEntity.status(403).build());
|
||||
}
|
||||
@@ -159,6 +158,7 @@ public class VanillaAufgabenGruppeController {
|
||||
AufgabenGruppeEntity entity = AufgabenGruppeEntity.create(gruppe);
|
||||
entity.setUserId(user.getUserId());
|
||||
entity.setPrivateGruppe(true);
|
||||
entity.setVanillaAvailable(true);
|
||||
gruppeRepository.save(entity);
|
||||
LOGGER.debug("User {} hat Vanilla-AufgabenGruppe '{}' ({}) erstellt", user.getUserId(), entity.getName(), entity.getGruppenId());
|
||||
return ResponseEntity.created(
|
||||
@@ -181,8 +181,7 @@ public class VanillaAufgabenGruppeController {
|
||||
AufgabenGruppeEntity entity = gruppeRepository.findById(gruppeId).orElse(null);
|
||||
if (entity == null) return ResponseEntity.notFound().build();
|
||||
if (!user.getUserId().equals(entity.getUserId())) return ResponseEntity.status(403).build();
|
||||
// Vanilla-safe check: cannot edit a non-vanilla-safe group
|
||||
if (!entity.getStrafen().isEmpty() || !entity.getSperren().isEmpty()) return ResponseEntity.status(403).build();
|
||||
if (!entity.isVanillaAvailable()) return ResponseEntity.status(403).build();
|
||||
|
||||
entity.setName(gruppe.getName().trim());
|
||||
entity.setBeschreibung(gruppe.getBeschreibung());
|
||||
@@ -202,10 +201,9 @@ public class VanillaAufgabenGruppeController {
|
||||
public ResponseEntity<Void> copy(@PathVariable("gruppeId") UUID gruppeId, Principal principal) {
|
||||
UserEntity user = resolveUser(principal);
|
||||
if (user == null) return ResponseEntity.status(401).build();
|
||||
// Only allow copying vanilla-safe groups
|
||||
AufgabenGruppeEntity source = gruppeRepository.findById(gruppeId).orElse(null);
|
||||
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 {
|
||||
aufgabenGruppeService.copyGruppe(gruppeId, user.getUserId());
|
||||
return ResponseEntity.status(201).build();
|
||||
@@ -228,8 +226,7 @@ public class VanillaAufgabenGruppeController {
|
||||
AufgabenGruppeEntity entity = gruppeRepository.findById(gruppeId).orElse(null);
|
||||
if (entity == null) return ResponseEntity.noContent().build();
|
||||
if (!user.getUserId().equals(entity.getUserId())) return ResponseEntity.status(403).build();
|
||||
// Only allow deletion of vanilla-safe groups
|
||||
if (!entity.getStrafen().isEmpty() || !entity.getSperren().isEmpty()) return ResponseEntity.status(403).build();
|
||||
if (!entity.isVanillaAvailable()) return ResponseEntity.status(403).build();
|
||||
|
||||
try {
|
||||
aboRepository.deleteByAufgabenGruppe(entity);
|
||||
@@ -249,7 +246,7 @@ public class VanillaAufgabenGruppeController {
|
||||
public ResponseEntity<Void> delete(@RequestBody AufgabenGruppe gruppe) {
|
||||
try {
|
||||
gruppeRepository.findById(gruppe.getGruppenId()).ifPresent(entity -> {
|
||||
if (entity.getStrafen().isEmpty() && entity.getSperren().isEmpty()) {
|
||||
if (entity.isVanillaAvailable()) {
|
||||
gruppeRepository.delete(entity);
|
||||
}
|
||||
});
|
||||
@@ -266,5 +263,4 @@ public class VanillaAufgabenGruppeController {
|
||||
if (principal == null) return null;
|
||||
return userService.requireUser(principal);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ public class NotificationController {
|
||||
n.put("text", m.getText());
|
||||
n.put("sentAt", m.getSentAt().toString());
|
||||
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 -> {
|
||||
n.put("senderName", sender.getName());
|
||||
n.put("senderAvatar", sender.getProfilePicture() != null ? sender.getProfilePicture() : "");
|
||||
@@ -67,6 +67,12 @@ public class NotificationController {
|
||||
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
|
||||
@PostMapping("/read-all")
|
||||
public ResponseEntity<Void> markAllRead(Principal principal) {
|
||||
|
||||
@@ -283,7 +283,8 @@ public class UserController {
|
||||
}
|
||||
|
||||
@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);
|
||||
if (userOpt.isEmpty()) return ResponseEntity.notFound().build();
|
||||
UserEntity user = userOpt.get();
|
||||
|
||||
@@ -120,7 +120,8 @@
|
||||
.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-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-card.open .gruppe-toggle { transform:rotate(90deg); }
|
||||
.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>
|
||||
</div>
|
||||
<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-actions">
|
||||
<button class="btn-cancel" id="gruppeModalCancel">Abbrechen</button>
|
||||
@@ -986,7 +993,7 @@ function renderAdminGruppen(gruppen) {
|
||||
<div class="gruppe-meta">
|
||||
<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-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>
|
||||
<span class="gruppe-toggle">▶</span>
|
||||
</div>
|
||||
@@ -1262,6 +1269,7 @@ function openGruppeModal(editId) {
|
||||
document.getElementById('gName').value = g.name || '';
|
||||
document.getElementById('gVon').value = g.von || '';
|
||||
document.getElementById('gDesc').value = g.beschreibung || '';
|
||||
document.getElementById('gVanilla').checked = g.vanillaAvailable || false;
|
||||
const imgWrap = document.getElementById('gCurrentImgWrap');
|
||||
if (g.bild) { document.getElementById('gCurrentImg').src = 'data:image/png;base64,' + g.bild; imgWrap.style.display = 'flex'; }
|
||||
else imgWrap.style.display = 'none';
|
||||
@@ -1270,6 +1278,7 @@ function openGruppeModal(editId) {
|
||||
document.getElementById('gName').value = '';
|
||||
document.getElementById('gVon').value = '';
|
||||
document.getElementById('gDesc').value = '';
|
||||
document.getElementById('gVanilla').checked = false;
|
||||
document.getElementById('gCurrentImgWrap').style.display = 'none';
|
||||
}
|
||||
gruppeModal.classList.add('open');
|
||||
@@ -1348,7 +1357,7 @@ gruppeModalSave.addEventListener('click', async () => {
|
||||
let bildBase64 = null;
|
||||
const fi = document.getElementById('gBild');
|
||||
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;
|
||||
fetch(isEdit ? `/admin/aufgabengruppen/${currentEditGruppeId}` : '/admin/aufgabengruppen', {
|
||||
method: isEdit ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload)
|
||||
|
||||
@@ -195,6 +195,7 @@ function renderPage(evt) {
|
||||
|
||||
const totalAttendees = (evt.attendees || []).length;
|
||||
const attending = evt.attendingMe;
|
||||
const isPast = new Date(evt.startAt) < new Date();
|
||||
|
||||
document.getElementById('content').innerHTML = `
|
||||
<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>
|
||||
</div>` : ''}
|
||||
<div style="display:flex;align-items:center;gap:0.4rem;flex-wrap:wrap;">
|
||||
<button class="btn" id="attendBtn"
|
||||
style="${attending ? 'background:var(--color-secondary);color:var(--color-text);' : ''}"
|
||||
onclick="toggleAttend()">
|
||||
${attending ? '✓ Ich bin dabei' : '+ Ich bin dabei'}
|
||||
</button>
|
||||
${isPast
|
||||
? `<span style="color:var(--color-muted);font-size:0.85rem;">Veranstaltung bereits beendet</span>`
|
||||
: `<button class="btn" id="attendBtn"
|
||||
style="${attending ? 'background:var(--color-secondary);color:var(--color-text);' : ''}"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -445,6 +445,9 @@ function renderPage() {
|
||||
${isOwner ? `<button class="btn" style="font-size:0.8rem;" onclick="openEventModal()">+ Veranstaltung erstellen</button>` : ''}
|
||||
</div>
|
||||
<div class="event-list" id="eventList"><p style="color:var(--color-muted);font-size:0.9rem;">Wird geladen…</p></div>
|
||||
<div id="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 class="section-title" style="margin-top:1.5rem;">Vergangene Veranstaltungen</div>
|
||||
<div class="event-list" id="pastEventList"></div>
|
||||
@@ -517,8 +520,8 @@ function renderPage() {
|
||||
${locHeaderHtml}
|
||||
${hoursHtml}
|
||||
${gallerySection}
|
||||
${feedSection}
|
||||
${eventsSection}`;
|
||||
${eventsSection}
|
||||
${feedSection}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -587,15 +590,27 @@ async function loadEvents() {
|
||||
if (!list) return;
|
||||
|
||||
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)
|
||||
.slice(-5) // letzte 5
|
||||
.slice(-3) // letzte 3
|
||||
.reverse(); // neueste zuerst
|
||||
|
||||
list.innerHTML = future.length
|
||||
? future.map(e => buildEventCard(e, false)).join('')
|
||||
const preview = future.slice(0, 3);
|
||||
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>';
|
||||
|
||||
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');
|
||||
if (past.length && pastSection) {
|
||||
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>
|
||||
</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}>
|
||||
<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 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>
|
||||
${adminBtns}
|
||||
@@ -1401,11 +1420,15 @@ function openLpLb(postId) {
|
||||
+ ' ' + 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 lbAuthorUrl = p.posterType === 'LOCATION'
|
||||
? `/community/location-detail.html?id=${p.locationId || p.authorId}`
|
||||
: `/community/benutzer.html?userId=${p.authorId}`;
|
||||
|
||||
document.getElementById('lbPostBody').innerHTML = `
|
||||
<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 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>
|
||||
</div>
|
||||
|
||||
164
src/main/resources/static/community/location-events.html
Normal file
164
src/main/resources/static/community/location-events.html
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -95,6 +95,7 @@
|
||||
}
|
||||
.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-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-card.open .gruppe-toggle { transform: rotate(90deg); }
|
||||
|
||||
@@ -367,6 +368,12 @@
|
||||
Gruppe veröffentlichen (für alle sichtbar)
|
||||
</span>
|
||||
</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-actions">
|
||||
<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>`);
|
||||
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 (g.vanillaAvailable) badges.push(`<span class="gruppe-badge gruppe-badge-vanilla">Vanilla</span>`);
|
||||
|
||||
return `
|
||||
<div class="gruppe-card" id="gruppe-${esc(g.gruppenId)}">
|
||||
@@ -1069,6 +1077,7 @@
|
||||
pubCb.checked = !g.privateGruppe;
|
||||
pubCb.disabled = g.privateGruppe; // Veröffentlichen nur über den Veröffentlichen-Button
|
||||
document.getElementById('gPublicLabel').style.display = 'block';
|
||||
document.getElementById('gVanilla').checked = g.vanillaAvailable || false;
|
||||
const imgWrap = document.getElementById('gCurrentImgWrap');
|
||||
if (g.bild) {
|
||||
document.getElementById('gCurrentImg').src = 'data:image/png;base64,' + g.bild;
|
||||
@@ -1086,6 +1095,7 @@
|
||||
document.getElementById('gDesc').value = '';
|
||||
document.getElementById('gPublic').checked = false;
|
||||
document.getElementById('gPublicLabel').style.display = 'none';
|
||||
document.getElementById('gVanilla').checked = false;
|
||||
document.getElementById('gCurrentImgWrap').style.display = 'none';
|
||||
gruppeModal.classList.add('open');
|
||||
document.getElementById('gName').focus();
|
||||
@@ -1119,6 +1129,7 @@
|
||||
name,
|
||||
beschreibung: document.getElementById('gDesc').value.trim() || null,
|
||||
privateGruppe: isEdit ? !document.getElementById('gPublic').checked : true,
|
||||
vanillaAvailable: document.getElementById('gVanilla').checked,
|
||||
bild: bildBase64
|
||||
};
|
||||
|
||||
|
||||
@@ -97,6 +97,7 @@
|
||||
.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-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; }
|
||||
.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; }
|
||||
@@ -653,9 +654,10 @@
|
||||
}
|
||||
ul.innerHTML = gruppen.map(g => {
|
||||
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' : ''}">
|
||||
<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="">` : ''}
|
||||
</label></li>`;
|
||||
}).join('');
|
||||
|
||||
@@ -887,11 +887,14 @@
|
||||
</div>`
|
||||
: '';
|
||||
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}')">
|
||||
<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 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>
|
||||
${ownBtns}
|
||||
|
||||
Reference in New Issue
Block a user