Weiter an den Locations gearbeitet
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.
BIN
bin/main/de/oaa/xxx/location/LocationAdminController.class
Normal file
BIN
bin/main/de/oaa/xxx/location/LocationAdminController.class
Normal file
Binary file not shown.
BIN
bin/main/de/oaa/xxx/location/LocationChatCleanupService.class
Normal file
BIN
bin/main/de/oaa/xxx/location/LocationChatCleanupService.class
Normal file
Binary file not shown.
BIN
bin/main/de/oaa/xxx/location/LocationController$AdminDto.class
Normal file
BIN
bin/main/de/oaa/xxx/location/LocationController$AdminDto.class
Normal file
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.
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.
BIN
bin/main/de/oaa/xxx/location/entity/LocationAdminEntity.class
Normal file
BIN
bin/main/de/oaa/xxx/location/entity/LocationAdminEntity.class
Normal file
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.
@@ -20,7 +20,7 @@
|
|||||||
.evt-date { font-size:0.88rem; color:var(--color-muted); margin-bottom:0.5rem; }
|
.evt-date { font-size:0.88rem; color:var(--color-muted); margin-bottom:0.5rem; }
|
||||||
.evt-desc { font-size:0.93rem; line-height:1.55; white-space:pre-wrap; word-break:break-word; margin-top:0.5rem; }
|
.evt-desc { font-size:0.93rem; line-height:1.55; white-space:pre-wrap; word-break:break-word; margin-top:0.5rem; }
|
||||||
|
|
||||||
.attend-btn { display:inline-flex; align-items:center; gap:0.4rem; margin-top:0.75rem; }
|
.attend-btn { display:inline-flex; align-items:center; gap:0.4rem; margin-top:0.75rem; flex-wrap:wrap; }
|
||||||
|
|
||||||
.section-title { font-size:1rem; font-weight:700; margin:1.5rem 0 0.75rem; }
|
.section-title { font-size:1rem; font-weight:700; margin:1.5rem 0 0.75rem; }
|
||||||
.gender-group { margin-bottom:1.25rem; }
|
.gender-group { margin-bottom:1.25rem; }
|
||||||
@@ -31,23 +31,92 @@
|
|||||||
.attendee-avatar { width:28px; height:28px; border-radius:50%; background:var(--color-secondary); object-fit:cover; flex-shrink:0; overflow:hidden; display:flex; align-items:center; justify-content:center; font-size:0.8rem; }
|
.attendee-avatar { width:28px; height:28px; border-radius:50%; background:var(--color-secondary); object-fit:cover; flex-shrink:0; overflow:hidden; display:flex; align-items:center; justify-content:center; font-size:0.8rem; }
|
||||||
.attendee-avatar img { width:100%; height:100%; object-fit:cover; }
|
.attendee-avatar img { width:100%; height:100%; object-fit:cover; }
|
||||||
.count-badge { background:var(--color-secondary); border-radius:12px; padding:0.15rem 0.6rem; font-size:0.78rem; color:var(--color-muted); margin-left:0.25rem; display:inline-block; }
|
.count-badge { background:var(--color-secondary); border-radius:12px; padding:0.15rem 0.6rem; font-size:0.78rem; color:var(--color-muted); margin-left:0.25rem; display:inline-block; }
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-overlay { display:none; position:fixed; inset:0; background:rgba(0,0,0,.6); z-index:200; align-items:center; justify-content:center; }
|
||||||
|
.modal-overlay.open { display:flex; }
|
||||||
|
.modal { background:var(--color-card); border-radius:12px; width:min(520px,95vw); max-height:90vh; overflow-y:auto; padding:1.5rem; }
|
||||||
|
.modal h3 { margin:0 0 1rem; }
|
||||||
|
.modal-footer { display:flex; gap:0.75rem; justify-content:flex-end; margin-top:1.25rem; flex-wrap:wrap; }
|
||||||
|
.img-preview { width:80px; height:80px; border-radius:8px; object-fit:cover; background:var(--color-secondary); display:flex; align-items:center; justify-content:center; font-size:1.5rem; flex-shrink:0; overflow:hidden; border:1px solid var(--color-secondary); }
|
||||||
|
.img-preview img { width:100%; height:100%; object-fit:cover; }
|
||||||
|
.img-row { display:flex; gap:0.75rem; align-items:center; margin-bottom:0.5rem; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="app">
|
||||||
<div class="main">
|
|
||||||
<a id="backLink" href="/community/events.html" class="back-link">← Veranstaltungen</a>
|
|
||||||
|
|
||||||
<div id="content">
|
<!-- ── Hinweis-Modal ──────────────────────────────────────────────────────────── -->
|
||||||
<p style="color:var(--color-muted);">Wird geladen…</p>
|
<div class="modal-overlay" id="alertModal">
|
||||||
|
<div class="modal" style="width:min(380px,95vw);">
|
||||||
|
<p id="alertMessage" style="margin:0 0 1.25rem;font-size:0.95rem;"></p>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn" onclick="document.getElementById('alertModal').classList.remove('open')">OK</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Bestätigungs-Modal ─────────────────────────────────────────────────────── -->
|
||||||
|
<div class="modal-overlay" id="confirmModal">
|
||||||
|
<div class="modal" style="width:min(380px,95vw);">
|
||||||
|
<h3 id="confirmTitle" style="margin:0 0 0.75rem;"></h3>
|
||||||
|
<p id="confirmMessage" style="margin:0 0 1.25rem;font-size:0.95rem;color:var(--color-muted);"></p>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn" style="background:var(--color-secondary);color:var(--color-text);" onclick="closeConfirm()">Abbrechen</button>
|
||||||
|
<button class="btn" id="confirmOkBtn" style="background:#c0392b;">Löschen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Edit-Modal ────────────────────────────────────────────────────────────── -->
|
||||||
|
<div class="modal-overlay" id="editModal">
|
||||||
|
<div class="modal">
|
||||||
|
<h3>Veranstaltung bearbeiten</h3>
|
||||||
|
<div class="img-row">
|
||||||
|
<div class="img-preview" id="editPicPreview">🗓</div>
|
||||||
|
<div>
|
||||||
|
<label class="btn" style="display:inline-block;cursor:pointer;font-size:0.85rem;">
|
||||||
|
Bild ändern
|
||||||
|
<input type="file" id="editPicFile" accept="image/*" style="display:none;" onchange="onEditPicChange(this)">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label>Titel *</label>
|
||||||
|
<input type="text" id="editTitle" maxlength="200">
|
||||||
|
<label>Beschreibung <span style="color:var(--color-muted);font-size:0.8rem;">(max. 1000 Zeichen)</span></label>
|
||||||
|
<textarea id="editDesc" maxlength="1000" rows="4"
|
||||||
|
style="resize:vertical;width:100%;box-sizing:border-box;padding:0.65rem 0.9rem;border:1px solid var(--color-secondary);border-radius:6px;background:var(--color-secondary);color:var(--color-text);font-size:1rem;outline:none;font-family:inherit;transition:border-color 0.2s;"
|
||||||
|
onfocus="this.style.borderColor='var(--color-primary)'" onblur="this.style.borderColor='var(--color-secondary)'"></textarea>
|
||||||
|
<label>Datum & Uhrzeit *</label>
|
||||||
|
<input type="datetime-local" id="editStartAt">
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn" style="background:var(--color-secondary);color:var(--color-text);" onclick="closeEditModal()">Abbrechen</button>
|
||||||
|
<button class="btn" id="editSubmitBtn" onclick="submitEdit()">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="main">
|
||||||
|
<div class="content">
|
||||||
|
<a id="backLink" href="/community/events.html" class="back-link">← Veranstaltungen</a>
|
||||||
|
<div id="content">
|
||||||
|
<p style="color:var(--color-muted);">Wird geladen…</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/sidebar.js"></script>
|
|
||||||
<script src="/js/icons.js"></script>
|
<script src="/js/icons.js"></script>
|
||||||
|
<script src="/js/sidebar.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
const eventId = params.get('id');
|
const eventId = params.get('id');
|
||||||
let myUserId = null;
|
let myUserId = null;
|
||||||
|
let _evtData = null;
|
||||||
|
let _editImg = null;
|
||||||
|
|
||||||
|
function showAlert(msg) {
|
||||||
|
document.getElementById('alertMessage').textContent = msg;
|
||||||
|
document.getElementById('alertModal').classList.add('open');
|
||||||
|
}
|
||||||
|
|
||||||
function escHtml(s) {
|
function escHtml(s) {
|
||||||
return (s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
return (s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
@@ -78,17 +147,16 @@ async function loadPage() {
|
|||||||
if (!evtRes.ok) { document.getElementById('content').innerHTML = '<p>Veranstaltung nicht gefunden.</p>'; return; }
|
if (!evtRes.ok) { document.getElementById('content').innerHTML = '<p>Veranstaltung nicht gefunden.</p>'; return; }
|
||||||
if (meRes.ok) { const me = await meRes.json(); myUserId = me.userId; }
|
if (meRes.ok) { const me = await meRes.json(); myUserId = me.userId; }
|
||||||
|
|
||||||
const evt = await evtRes.json();
|
_evtData = await evtRes.json();
|
||||||
|
|
||||||
// Rücklink zur Location
|
|
||||||
const backLink = document.getElementById('backLink');
|
const backLink = document.getElementById('backLink');
|
||||||
if (evt.locationId) {
|
if (_evtData.locationId) {
|
||||||
backLink.href = `/community/location-detail.html?id=${evt.locationId}`;
|
backLink.href = `/community/location-detail.html?id=${_evtData.locationId}`;
|
||||||
backLink.textContent = `← ${escHtml(evt.locationName) || 'Location'}`;
|
backLink.textContent = `← ${_evtData.locationName || 'Location'}`;
|
||||||
}
|
}
|
||||||
document.title = `${evt.title} – xXx Sphere`;
|
document.title = `${_evtData.title} – xXx Sphere`;
|
||||||
|
|
||||||
renderPage(evt);
|
renderPage(_evtData);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPage(evt) {
|
function renderPage(evt) {
|
||||||
@@ -96,7 +164,9 @@ function renderPage(evt) {
|
|||||||
? `<img src="data:image/jpeg;base64,${evt.imageData}" alt="${escHtml(evt.title)}">`
|
? `<img src="data:image/jpeg;base64,${evt.imageData}" alt="${escHtml(evt.title)}">`
|
||||||
: '🗓';
|
: '🗓';
|
||||||
|
|
||||||
// Teilnehmende nach Geschlecht gruppieren
|
const isFuture = new Date(evt.startAt) > new Date();
|
||||||
|
const canEdit = !!evt.isAdmin && isFuture;
|
||||||
|
|
||||||
const byGender = {};
|
const byGender = {};
|
||||||
(evt.attendees || []).forEach(a => {
|
(evt.attendees || []).forEach(a => {
|
||||||
const g = a.geschlecht || 'UNBEKANNT';
|
const g = a.geschlecht || 'UNBEKANNT';
|
||||||
@@ -134,7 +204,13 @@ function renderPage(evt) {
|
|||||||
${evt.locationName ? `<div class="evt-location">📍 <a href="/community/location-detail.html?id=${evt.locationId}" style="color:inherit;text-decoration:none;">${escHtml(evt.locationName)}</a></div>` : ''}
|
${evt.locationName ? `<div class="evt-location">📍 <a href="/community/location-detail.html?id=${evt.locationId}" style="color:inherit;text-decoration:none;">${escHtml(evt.locationName)}</a></div>` : ''}
|
||||||
<div class="evt-date">🗓 ${formatDate(evt.startAt)}</div>
|
<div class="evt-date">🗓 ${formatDate(evt.startAt)}</div>
|
||||||
${evt.description ? `<div class="evt-desc">${escHtml(evt.description)}</div>` : ''}
|
${evt.description ? `<div class="evt-desc">${escHtml(evt.description)}</div>` : ''}
|
||||||
<div class="attend-btn">
|
<div style="display:flex;flex-direction:column;gap:0.5rem;margin-top:0.75rem;">
|
||||||
|
${canEdit ? `
|
||||||
|
<div style="display:flex;align-items:center;gap:0.4rem;flex-wrap:wrap;">
|
||||||
|
<button class="btn" style="background:var(--color-secondary);color:var(--color-text);font-size:0.85rem;" onclick="openEditModal()">✎ Bearbeiten</button>
|
||||||
|
<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"
|
<button class="btn" id="attendBtn"
|
||||||
style="${attending ? 'background:var(--color-secondary);color:var(--color-text);' : ''}"
|
style="${attending ? 'background:var(--color-secondary);color:var(--color-text);' : ''}"
|
||||||
onclick="toggleAttend()">
|
onclick="toggleAttend()">
|
||||||
@@ -144,6 +220,7 @@ function renderPage(evt) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
${totalAttendees > 0 ? `
|
${totalAttendees > 0 ? `
|
||||||
<div class="section-title">Teilnehmende</div>
|
<div class="section-title">Teilnehmende</div>
|
||||||
@@ -154,9 +231,8 @@ function renderPage(evt) {
|
|||||||
|
|
||||||
async function toggleAttend() {
|
async function toggleAttend() {
|
||||||
const res = await fetch(`/location-events/${eventId}/attend`, { method: 'POST' });
|
const res = await fetch(`/location-events/${eventId}/attend`, { method: 'POST' });
|
||||||
if (!res.ok) { alert('Fehler beim Aktualisieren.'); return; }
|
if (!res.ok) { showAlert('Fehler beim Aktualisieren.'); return; }
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
const btn = document.getElementById('attendBtn');
|
const btn = document.getElementById('attendBtn');
|
||||||
const countEl = document.getElementById('attendCount');
|
const countEl = document.getElementById('attendCount');
|
||||||
if (btn) {
|
if (btn) {
|
||||||
@@ -165,10 +241,107 @@ async function toggleAttend() {
|
|||||||
btn.style.color = data.attending ? 'var(--color-text)' : '';
|
btn.style.color = data.attending ? 'var(--color-text)' : '';
|
||||||
}
|
}
|
||||||
if (countEl) countEl.textContent = `${data.attendeeCount} Teilnehmer*in(nen)`;
|
if (countEl) countEl.textContent = `${data.attendeeCount} Teilnehmer*in(nen)`;
|
||||||
|
|
||||||
// Teilnehmendenliste neu laden
|
|
||||||
const evtRes = await fetch(`/location-events/${eventId}`);
|
const evtRes = await fetch(`/location-events/${eventId}`);
|
||||||
if (evtRes.ok) { renderPage(await evtRes.json()); }
|
if (evtRes.ok) { _evtData = await evtRes.json(); renderPage(_evtData); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Edit Modal ────────────────────────────────────────────────────────────────
|
||||||
|
function resizeImage(file, maxPx, quality) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
img.onload = () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
let w = img.naturalWidth, h = img.naturalHeight;
|
||||||
|
if (w > maxPx || h > maxPx) {
|
||||||
|
if (w >= h) { h = Math.max(1, Math.round(maxPx * h / w)); w = maxPx; }
|
||||||
|
else { w = Math.max(1, Math.round(maxPx * w / h)); h = maxPx; }
|
||||||
|
}
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = w; canvas.height = h;
|
||||||
|
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
|
||||||
|
resolve(canvas.toDataURL('image/jpeg', quality || 0.85).split(',')[1]);
|
||||||
|
};
|
||||||
|
img.onerror = reject;
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditModal() {
|
||||||
|
if (!_evtData) return;
|
||||||
|
_editImg = null;
|
||||||
|
document.getElementById('editTitle').value = _evtData.title || '';
|
||||||
|
document.getElementById('editDesc').value = _evtData.description || '';
|
||||||
|
const nowLocal = new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().slice(0, 16);
|
||||||
|
const startVal = _evtData.startAt ? _evtData.startAt.slice(0, 16) : '';
|
||||||
|
document.getElementById('editStartAt').min = nowLocal;
|
||||||
|
document.getElementById('editStartAt').value = startVal;
|
||||||
|
const preview = document.getElementById('editPicPreview');
|
||||||
|
preview.innerHTML = _evtData.imageData
|
||||||
|
? `<img src="data:image/jpeg;base64,${_evtData.imageData}" alt="">`
|
||||||
|
: '🗓';
|
||||||
|
document.getElementById('editModal').classList.add('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditModal() { document.getElementById('editModal').classList.remove('open'); }
|
||||||
|
|
||||||
|
async function onEditPicChange(input) {
|
||||||
|
const file = input.files[0]; if (!file) return;
|
||||||
|
_editImg = await resizeImage(file, 1024, 0.88);
|
||||||
|
document.getElementById('editPicPreview').innerHTML = `<img src="data:image/jpeg;base64,${_editImg}" alt="">`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitEdit() {
|
||||||
|
const title = document.getElementById('editTitle').value.trim();
|
||||||
|
const desc = document.getElementById('editDesc').value;
|
||||||
|
const startAt = document.getElementById('editStartAt').value;
|
||||||
|
if (!title) { showAlert('Bitte gib einen Titel ein.'); return; }
|
||||||
|
if (!startAt) { showAlert('Bitte wähle Datum und Uhrzeit.'); return; }
|
||||||
|
if (new Date(startAt) <= new Date()) { showAlert('Der Termin muss in der Zukunft liegen.'); return; }
|
||||||
|
|
||||||
|
const btn = document.getElementById('editSubmitBtn');
|
||||||
|
btn.disabled = true; btn.textContent = 'Wird gespeichert…';
|
||||||
|
try {
|
||||||
|
const body = { title, description: desc };
|
||||||
|
if (startAt) body.startAt = startAt + ':00';
|
||||||
|
if (_editImg) body.imageData = _editImg;
|
||||||
|
|
||||||
|
const res = await fetch(`/locations/${_evtData.locationId}/events/${eventId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
if (res.status === 422) { showAlert('Der Termin muss in der Zukunft liegen.'); return; }
|
||||||
|
if (!res.ok) { showAlert('Fehler beim Speichern.'); return; }
|
||||||
|
_evtData = await res.json();
|
||||||
|
closeEditModal();
|
||||||
|
renderPage(_evtData);
|
||||||
|
document.title = `${_evtData.title} – xXx Sphere`;
|
||||||
|
} catch { showAlert('Fehler beim Speichern.'); }
|
||||||
|
finally { btn.disabled = false; btn.textContent = 'Speichern'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Confirm / Delete ──────────────────────────────────────────────────────────
|
||||||
|
let _confirmCallback = null;
|
||||||
|
function openConfirm(title, message, onOk) {
|
||||||
|
document.getElementById('confirmTitle').textContent = title;
|
||||||
|
document.getElementById('confirmMessage').textContent = message;
|
||||||
|
_confirmCallback = onOk;
|
||||||
|
document.getElementById('confirmModal').classList.add('open');
|
||||||
|
}
|
||||||
|
function closeConfirm() { document.getElementById('confirmModal').classList.remove('open'); _confirmCallback = null; }
|
||||||
|
document.getElementById('confirmOkBtn').addEventListener('click', () => { if (_confirmCallback) _confirmCallback(); closeConfirm(); });
|
||||||
|
|
||||||
|
function openDeleteConfirm() {
|
||||||
|
openConfirm(
|
||||||
|
'Veranstaltung löschen',
|
||||||
|
'Soll diese Veranstaltung wirklich gelöscht werden? Alle angemeldeten Teilnehmenden werden benachrichtigt.',
|
||||||
|
async () => {
|
||||||
|
const res = await fetch(`/locations/${_evtData.locationId}/events/${eventId}`, { method: 'DELETE' });
|
||||||
|
if (!res.ok) { showAlert('Fehler beim Löschen.'); return; }
|
||||||
|
location.href = `/community/location-detail.html?id=${_evtData.locationId}`;
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
loadPage();
|
loadPage();
|
||||||
|
|||||||
@@ -273,7 +273,15 @@
|
|||||||
: '';
|
: '';
|
||||||
|
|
||||||
const gruppeIdAttr = p.gruppeId ? ` data-gruppe-id="${p.gruppeId}"` : '';
|
const gruppeIdAttr = p.gruppeId ? ` data-gruppe-id="${p.gruppeId}"` : '';
|
||||||
return `<div class="post-card" id="pc-${p.postId}"${gruppeIdAttr} onclick="openLb('${p.postId}','${p.postType}')" style="cursor:pointer;">
|
const hasTarget = !!p.targetUrl;
|
||||||
|
const cardClick = hasTarget
|
||||||
|
? `window.location.href='${p.targetUrl}'`
|
||||||
|
: `openLb('${p.postId}','${p.postType}')`;
|
||||||
|
const commentBtn = hasTarget ? '' : `
|
||||||
|
<button class="post-action-btn" onclick="event.stopPropagation(); openLb('${p.postId}','${p.postType}')">
|
||||||
|
💬 <span id="kc-${p.postId}">${p.kommentarCount}</span>
|
||||||
|
</button>`;
|
||||||
|
return `<div class="post-card" id="pc-${p.postId}"${gruppeIdAttr} onclick="${cardClick}" style="cursor:pointer;">
|
||||||
<div class="post-header">
|
<div class="post-header">
|
||||||
<div class="post-avatar">${avatarHtml}</div>
|
<div class="post-avatar">${avatarHtml}</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -289,9 +297,7 @@
|
|||||||
<button class="post-action-btn${p.likedByMe ? ' active' : ''}" id="lk-${p.postId}" onclick="event.stopPropagation(); likePost('${p.postId}','${p.postType}')">
|
<button class="post-action-btn${p.likedByMe ? ' active' : ''}" id="lk-${p.postId}" onclick="event.stopPropagation(); likePost('${p.postId}','${p.postType}')">
|
||||||
♥ <span id="lkc-${p.postId}">${p.likeCount}</span>
|
♥ <span id="lkc-${p.postId}">${p.likeCount}</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="post-action-btn" onclick="event.stopPropagation(); openLb('${p.postId}','${p.postType}')">
|
${commentBtn}
|
||||||
💬 <span id="kc-${p.postId}">${p.kommentarCount}</span>
|
|
||||||
</button>
|
|
||||||
${meldenBtn}
|
${meldenBtn}
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
.back-link:hover { color:var(--color-primary); }
|
.back-link:hover { color:var(--color-primary); }
|
||||||
|
|
||||||
.loc-header { display:flex; gap:1rem; align-items:flex-start; margin-bottom:1.25rem; flex-wrap:wrap; }
|
.loc-header { display:flex; gap:1rem; align-items:flex-start; margin-bottom:1.25rem; flex-wrap:wrap; }
|
||||||
.loc-avatar { width:96px; height:96px; border-radius:12px; background:var(--color-secondary); object-fit:cover; flex-shrink:0; display:flex; align-items:center; justify-content:center; font-size:2.5rem; overflow:hidden; border:2px solid var(--color-secondary); }
|
.loc-avatar { width:120px; height:120px; border-radius:12px; background:var(--color-secondary); object-fit:cover; flex-shrink:0; display:flex; align-items:center; justify-content:center; font-size:2.5rem; overflow:hidden; border:2px solid var(--color-secondary); }
|
||||||
.loc-avatar img { width:100%; height:100%; object-fit:cover; }
|
.loc-avatar img { width:100%; height:100%; object-fit:cover; }
|
||||||
.loc-meta { flex:1; min-width:0; }
|
.loc-meta { flex:1; min-width:0; }
|
||||||
.loc-name { font-size:1.4rem; font-weight:700; margin:0 0 0.3rem; }
|
.loc-name { font-size:1.4rem; font-weight:700; margin:0 0 0.3rem; }
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
.hours-table td:first-child { font-weight:500; width:100px; }
|
.hours-table td:first-child { font-weight:500; width:100px; }
|
||||||
.hours-closed { color:var(--color-muted); }
|
.hours-closed { color:var(--color-muted); }
|
||||||
|
|
||||||
.gallery-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(120px,1fr)); gap:0.6rem; }
|
.gallery-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(180px,1fr)); gap:0.6rem; }
|
||||||
.gallery-img-wrap { position:relative; aspect-ratio:1; border-radius:8px; overflow:hidden; background:var(--color-secondary); }
|
.gallery-img-wrap { position:relative; aspect-ratio:1; border-radius:8px; overflow:hidden; background:var(--color-secondary); }
|
||||||
.gallery-img-wrap img { width:100%; height:100%; object-fit:cover; cursor:pointer; transition:opacity 0.15s; }
|
.gallery-img-wrap img { width:100%; height:100%; object-fit:cover; cursor:pointer; transition:opacity 0.15s; }
|
||||||
.gallery-img-wrap img:hover { opacity:0.88; }
|
.gallery-img-wrap img:hover { opacity:0.88; }
|
||||||
@@ -59,20 +59,73 @@
|
|||||||
/* Lightbox */
|
/* Lightbox */
|
||||||
.lb { display:none; position:fixed; inset:0; background:rgba(0,0,0,.9); z-index:300; align-items:center; justify-content:center; }
|
.lb { display:none; position:fixed; inset:0; background:rgba(0,0,0,.9); z-index:300; align-items:center; justify-content:center; }
|
||||||
.lb.open { display:flex; }
|
.lb.open { display:flex; }
|
||||||
.lb img { max-width:95vw; max-height:95vh; border-radius:8px; object-fit:contain; }
|
.lb img { max-width:85vw; max-height:90vh; border-radius:8px; object-fit:contain; }
|
||||||
.lb-close { position:absolute; top:1rem; right:1rem; background:none; border:none; color:#fff; font-size:1.5rem; cursor:pointer; }
|
.lb-close { position:absolute; top:1rem; right:1rem; background:none; border:none; color:#fff; font-size:1.5rem; cursor:pointer; }
|
||||||
|
.lb-nav { position:absolute; top:50%; transform:translateY(-50%); background:rgba(255,255,255,.15); border:none; color:#fff; font-size:2.5rem; line-height:1; cursor:pointer; padding:0.3rem 0.8rem; border-radius:8px; transition:background 0.15s; user-select:none; }
|
||||||
|
.lb-nav:hover { background:rgba(255,255,255,.3); }
|
||||||
|
.lb-nav:disabled { opacity:0.2; cursor:default; }
|
||||||
|
.lb-prev { left:1rem; }
|
||||||
|
.lb-next { right:1rem; }
|
||||||
|
|
||||||
.owner-badge { display:inline-flex; align-items:center; gap:0.3rem; font-size:0.75rem; background:var(--color-secondary); border-radius:4px; padding:0.2rem 0.5rem; color:var(--color-muted); margin-top:0.3rem; }
|
.owner-badge { display:inline-flex; align-items:center; gap:0.3rem; font-size:0.75rem; background:var(--color-secondary); border-radius:4px; padding:0.2rem 0.5rem; color:var(--color-muted); margin-top:0.3rem; }
|
||||||
.owner-actions { display:flex; gap:0.5rem; flex-wrap:wrap; margin-top:0.75rem; }
|
.owner-actions { display:flex; gap:0.5rem; flex-wrap:wrap; margin-top:0.75rem; }
|
||||||
|
|
||||||
|
/* Tabs */
|
||||||
|
.tab-bar { display:flex; gap:0; border-bottom:2px solid var(--color-secondary); margin-bottom:1.25rem; flex-wrap:wrap; }
|
||||||
|
.tab-btn { background:none; border:none; border-bottom:2px solid transparent; color:var(--color-muted); cursor:pointer; font-size:0.88rem; font-weight:600; padding:0.65rem 1rem; margin-bottom:-2px; border-radius:0; width:auto; margin-top:0; transition:color 0.15s, border-color 0.15s; white-space:nowrap; }
|
||||||
|
.tab-btn:hover { color:var(--color-text); background:none; }
|
||||||
|
.tab-btn.active { color:var(--color-text); border-bottom-color:var(--color-primary); }
|
||||||
|
.tab-panel { display:none; }
|
||||||
|
.tab-panel.active { display:block; }
|
||||||
|
|
||||||
|
/* Posteingang */
|
||||||
|
.inbox-list { display:flex; flex-direction:column; gap:0.5rem; }
|
||||||
|
.inbox-item { display:flex; align-items:center; gap:0.65rem; padding:0.65rem 0.75rem; border-radius:8px; background:var(--color-secondary); cursor:pointer; transition:opacity 0.15s; }
|
||||||
|
.inbox-item:hover { opacity:0.85; }
|
||||||
|
.inbox-avatar { width:36px; height:36px; border-radius:50%; background:var(--color-card); display:flex; align-items:center; justify-content:center; font-size:0.9rem; flex-shrink:0; overflow:hidden; }
|
||||||
|
.inbox-avatar img { width:100%; height:100%; object-fit:cover; }
|
||||||
|
.inbox-info { flex:1; min-width:0; }
|
||||||
|
.inbox-name { font-weight:600; font-size:0.88rem; }
|
||||||
|
.inbox-preview { font-size:0.78rem; color:var(--color-muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||||
|
.inbox-unread { background:var(--color-primary); color:#fff; font-size:0.65rem; font-weight:700; border-radius:9999px; padding:0.1rem 0.35rem; flex-shrink:0; }
|
||||||
|
|
||||||
|
/* Posteingang Chat */
|
||||||
|
.inbox-chat { display:none; flex-direction:column; gap:0; margin-top:0.75rem; border:1px solid var(--color-secondary); border-radius:10px; overflow:hidden; }
|
||||||
|
.inbox-chat.open { display:flex; }
|
||||||
|
.inbox-chat-header { display:flex; align-items:center; gap:0.5rem; padding:0.65rem 0.9rem; background:var(--color-secondary); font-weight:600; font-size:0.88rem; }
|
||||||
|
.inbox-chat-back { background:none; border:none; color:var(--color-muted); cursor:pointer; font-size:1.1rem; padding:0; margin:0; width:auto; line-height:1; }
|
||||||
|
.inbox-chat-close { background:none; border:none; color:var(--color-muted); cursor:pointer; font-size:1rem; padding:0.15rem 0.35rem; margin:0 0 0 auto; width:auto; line-height:1; border-radius:4px; transition:background 0.15s, color 0.15s; }
|
||||||
|
.inbox-chat-close:hover { background:var(--color-card); color:var(--color-text); }
|
||||||
|
.inbox-chat-messages { max-height:320px; overflow-y:auto; padding:0.75rem 1rem; display:flex; flex-direction:column; gap:0.4rem; }
|
||||||
|
.inbox-bubble-wrap { display:flex; flex-direction:column; }
|
||||||
|
.inbox-bubble-wrap.me { align-items:flex-end; }
|
||||||
|
.inbox-bubble-wrap.them { align-items:flex-start; }
|
||||||
|
.inbox-bubble { max-width:75%; padding:0.45rem 0.8rem; border-radius:12px; font-size:0.88rem; line-height:1.4; word-break:break-word; }
|
||||||
|
.inbox-bubble-wrap.me .inbox-bubble { background:var(--color-primary); color:#fff; border-bottom-right-radius:4px; }
|
||||||
|
.inbox-bubble-wrap.them .inbox-bubble { background:var(--color-secondary); color:var(--color-text); border-bottom-left-radius:4px; }
|
||||||
|
.inbox-bubble-time { font-size:0.68rem; color:var(--color-muted); margin-top:0.1rem; padding:0 0.2rem; }
|
||||||
|
.inbox-reply-area { display:flex; gap:0.5rem; padding:0.6rem 0.9rem; border-top:1px solid var(--color-secondary); align-items:center; }
|
||||||
|
.inbox-reply-area input { flex:1; }
|
||||||
|
.inbox-reply-btn { width:auto; margin-top:0; padding:0.5rem 1rem; flex-shrink:0; }
|
||||||
|
.inbox-lock-hint { font-size:0.8rem; color:var(--color-muted); padding:0.5rem 0.9rem; border-top:1px solid var(--color-secondary); background:var(--color-secondary); }
|
||||||
|
.inbox-reply-trigger { padding:0.6rem 0.9rem; border-top:1px solid var(--color-secondary); }
|
||||||
|
.inbox-reply-trigger .btn { font-size:0.85rem; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="app">
|
||||||
|
|
||||||
|
<!-- ── Admin-Autocomplete (außerhalb .main, damit overflow-y:auto nicht clippt) ── -->
|
||||||
|
<ul id="adminSearchList" style="position:fixed;display:none;background:var(--color-card);border:1px solid var(--color-secondary);border-radius:6px;z-index:400;list-style:none;margin:0;padding:0;max-height:200px;overflow-y:auto;box-shadow:0 4px 12px rgba(0,0,0,.15);"></ul>
|
||||||
|
<ul id="ownerSearchList" style="position:fixed;display:none;background:var(--color-card);border:1px solid var(--color-secondary);border-radius:6px;z-index:400;list-style:none;margin:0;padding:0;max-height:200px;overflow-y:auto;box-shadow:0 4px 12px rgba(0,0,0,.15);"></ul>
|
||||||
|
|
||||||
<div class="main">
|
<div class="main">
|
||||||
|
<div class="content">
|
||||||
<a href="/community/locations.html" class="back-link">← Locations</a>
|
<a href="/community/locations.html" class="back-link">← Locations</a>
|
||||||
|
|
||||||
<div id="content">
|
<div id="content">
|
||||||
<p style="color:var(--color-muted);">Wird geladen…</p>
|
<p style="color:var(--color-muted);">Wird geladen…</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Edit-Modal ──────────────────────────────────────────────────────────── -->
|
<!-- ── Edit-Modal ──────────────────────────────────────────────────────────── -->
|
||||||
@@ -131,7 +184,7 @@
|
|||||||
<label>Titel *</label>
|
<label>Titel *</label>
|
||||||
<input type="text" id="eventTitle" maxlength="200">
|
<input type="text" id="eventTitle" maxlength="200">
|
||||||
<label>Beschreibung <span style="color:var(--color-muted);font-size:0.8rem;">(max. 1000 Zeichen)</span></label>
|
<label>Beschreibung <span style="color:var(--color-muted);font-size:0.8rem;">(max. 1000 Zeichen)</span></label>
|
||||||
<textarea id="eventDesc" maxlength="1000" rows="4" style="resize:vertical;"></textarea>
|
<textarea id="eventDesc" maxlength="1000" rows="4" style="resize:vertical;width:100%;box-sizing:border-box;padding:0.65rem 0.9rem;border:1px solid var(--color-secondary);border-radius:6px;background:var(--color-secondary);color:var(--color-text);font-size:1rem;outline:none;font-family:inherit;transition:border-color 0.2s;" onfocus="this.style.borderColor='var(--color-primary)'" onblur="this.style.borderColor='var(--color-secondary)'"></textarea>
|
||||||
<label>Datum & Uhrzeit *</label>
|
<label>Datum & Uhrzeit *</label>
|
||||||
<input type="datetime-local" id="eventStartAt">
|
<input type="datetime-local" id="eventStartAt">
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
@@ -141,20 +194,45 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Hinweis-Modal ──────────────────────────────────────────────────────── -->
|
||||||
|
<div class="modal-overlay" id="alertModal">
|
||||||
|
<div class="modal" style="width:min(380px,95vw);">
|
||||||
|
<p id="alertMessage" style="margin:0 0 1.25rem;font-size:0.95rem;"></p>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn" onclick="document.getElementById('alertModal').classList.remove('open')">OK</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Bestätigungs-Modal ─────────────────────────────────────────────────── -->
|
||||||
|
<div class="modal-overlay" id="confirmModal">
|
||||||
|
<div class="modal" style="width:min(380px,95vw);">
|
||||||
|
<h3 id="confirmTitle">Bestätigung</h3>
|
||||||
|
<p id="confirmMessage" style="color:var(--color-muted);font-size:0.92rem;margin:0 0 0.25rem;"></p>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn" style="background:var(--color-secondary);color:var(--color-text);" onclick="closeConfirm()">Abbrechen</button>
|
||||||
|
<button class="btn" id="confirmOkBtn" style="background:#c0392b;">Löschen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ── Galerie Lightbox ───────────────────────────────────────────────────── -->
|
<!-- ── Galerie Lightbox ───────────────────────────────────────────────────── -->
|
||||||
<div class="lb" id="lightbox" onclick="closeLightbox()">
|
<div class="lb" id="lightbox" onclick="closeLightbox()">
|
||||||
<button class="lb-close" onclick="closeLightbox()">✕</button>
|
<button class="lb-close" onclick="closeLightbox()">✕</button>
|
||||||
<img id="lbImg" src="" alt="">
|
<button class="lb-nav lb-prev" id="lbPrev" onclick="event.stopPropagation();lbNav(-1)">‹</button>
|
||||||
|
<img id="lbImg" src="" alt="" onclick="event.stopPropagation()">
|
||||||
|
<button class="lb-nav lb-next" id="lbNext" onclick="event.stopPropagation();lbNav(1)">›</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/sidebar.js"></script>
|
|
||||||
<script src="/js/icons.js"></script>
|
<script src="/js/icons.js"></script>
|
||||||
|
<script src="/js/sidebar.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
const locationId = params.get('id');
|
const locationId = params.get('id');
|
||||||
let locDetail = null;
|
let locDetail = null;
|
||||||
let myUserId = null;
|
let myUserId = null;
|
||||||
let isOwner = false;
|
let isOwner = false;
|
||||||
|
let isAdmin = false;
|
||||||
let isFollowing = false;
|
let isFollowing = false;
|
||||||
|
|
||||||
// ── Bild-Resize ───────────────────────────────────────────────────────────────
|
// ── Bild-Resize ───────────────────────────────────────────────────────────────
|
||||||
@@ -192,6 +270,18 @@ function formatDate(dt) {
|
|||||||
+ ', ' + d.toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' }) + ' Uhr';
|
+ ', ' + d.toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' }) + ' Uhr';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Tabs ──────────────────────────────────────────────────────────────────────
|
||||||
|
const VALID_TABS = ['grunddaten', 'admins', 'posteingang', 'veranstaltungen'];
|
||||||
|
|
||||||
|
function switchTab(name) {
|
||||||
|
if (!VALID_TABS.includes(name)) name = 'grunddaten';
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(btn =>
|
||||||
|
btn.classList.toggle('active', btn.dataset.tab === name));
|
||||||
|
document.querySelectorAll('.tab-panel').forEach(panel =>
|
||||||
|
panel.classList.toggle('active', panel.id === 'tab-' + name));
|
||||||
|
history.replaceState(null, '', location.pathname + location.search + '#' + name);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Lade Seite ────────────────────────────────────────────────────────────────
|
// ── Lade Seite ────────────────────────────────────────────────────────────────
|
||||||
async function loadPage() {
|
async function loadPage() {
|
||||||
if (!locationId) { document.getElementById('content').innerHTML = '<p>Keine Location-ID angegeben.</p>'; return; }
|
if (!locationId) { document.getElementById('content').innerHTML = '<p>Keine Location-ID angegeben.</p>'; return; }
|
||||||
@@ -209,10 +299,22 @@ async function loadPage() {
|
|||||||
|
|
||||||
locDetail = await locRes.json();
|
locDetail = await locRes.json();
|
||||||
isOwner = locDetail.ownerId === myUserId;
|
isOwner = locDetail.ownerId === myUserId;
|
||||||
|
isAdmin = isOwner || !!locDetail.isAdmin;
|
||||||
isFollowing = !!locDetail.following;
|
isFollowing = !!locDetail.following;
|
||||||
|
|
||||||
renderPage();
|
renderPage();
|
||||||
|
|
||||||
|
if (isAdmin) {
|
||||||
|
const chatWithId = new URLSearchParams(location.search).get('chatWith');
|
||||||
|
const hash = location.hash.replace('#', '');
|
||||||
|
const initialTab = chatWithId ? 'posteingang' : (VALID_TABS.includes(hash) ? hash : 'grunddaten');
|
||||||
|
switchTab(initialTab);
|
||||||
|
renderAdminList();
|
||||||
|
loadInbox();
|
||||||
loadEvents();
|
loadEvents();
|
||||||
|
} else {
|
||||||
|
loadEvents();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPage() {
|
function renderPage() {
|
||||||
@@ -230,6 +332,7 @@ function renderPage() {
|
|||||||
<button class="btn" id="followBtn" style="font-size:0.85rem;${isFollowing ? 'background:var(--color-primary);color:#fff;' : 'background:var(--color-secondary);color:var(--color-text);'}" onclick="toggleFollow()">
|
<button class="btn" id="followBtn" style="font-size:0.85rem;${isFollowing ? 'background:var(--color-primary);color:#fff;' : 'background:var(--color-secondary);color:var(--color-text);'}" onclick="toggleFollow()">
|
||||||
${isFollowing ? '★ Abonniert' : '☆ Abonnieren'}
|
${isFollowing ? '★ Abonniert' : '☆ Abonnieren'}
|
||||||
</button>
|
</button>
|
||||||
|
${loc.virtualUserId && myUserId && !isAdmin ? `<button class="btn" style="font-size:0.85rem;" onclick="contactLocation('${loc.virtualUserId}')">✉ Kontaktieren</button>` : ''}
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
let hoursHtml = '';
|
let hoursHtml = '';
|
||||||
@@ -248,7 +351,7 @@ function renderPage() {
|
|||||||
|
|
||||||
const galleryHtml = buildGalleryHtml(loc.gallery || []);
|
const galleryHtml = buildGalleryHtml(loc.gallery || []);
|
||||||
|
|
||||||
document.getElementById('content').innerHTML = `
|
const locHeaderHtml = `
|
||||||
<div class="loc-header">
|
<div class="loc-header">
|
||||||
<div class="loc-avatar">${imgHtml}</div>
|
<div class="loc-avatar">${imgHtml}</div>
|
||||||
<div class="loc-meta">
|
<div class="loc-meta">
|
||||||
@@ -257,10 +360,9 @@ function renderPage() {
|
|||||||
${loc.description ? `<div class="loc-desc">${escHtml(loc.description)}</div>` : ''}
|
${loc.description ? `<div class="loc-desc">${escHtml(loc.description)}</div>` : ''}
|
||||||
${ownerActions}
|
${ownerActions}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>`;
|
||||||
|
|
||||||
${hoursHtml}
|
|
||||||
|
|
||||||
|
const gallerySection = `
|
||||||
<div class="section-title">
|
<div class="section-title">
|
||||||
Galerie
|
Galerie
|
||||||
${isOwner ? `<label class="btn" style="font-size:0.8rem;cursor:pointer;">
|
${isOwner ? `<label class="btn" style="font-size:0.8rem;cursor:pointer;">
|
||||||
@@ -268,14 +370,87 @@ function renderPage() {
|
|||||||
<input type="file" accept="image/*" style="display:none;" onchange="uploadGalleryImage(this)">
|
<input type="file" accept="image/*" style="display:none;" onchange="uploadGalleryImage(this)">
|
||||||
</label>` : ''}
|
</label>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="gallery-grid" id="galleryGrid">${galleryHtml}</div>
|
<div class="gallery-grid" id="galleryGrid">${galleryHtml}</div>`;
|
||||||
|
|
||||||
|
const eventsSection = `
|
||||||
<div class="section-title">
|
<div class="section-title">
|
||||||
Veranstaltungen
|
Veranstaltungen
|
||||||
${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="pastEventsSection" style="display:none;">
|
||||||
|
<div class="section-title" style="margin-top:1.5rem;">Vergangene Veranstaltungen</div>
|
||||||
|
<div class="event-list" id="pastEventList"></div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
if (isAdmin) {
|
||||||
|
document.getElementById('content').innerHTML = `
|
||||||
|
<div class="tab-bar">
|
||||||
|
<button class="tab-btn" data-tab="grunddaten" onclick="switchTab('grunddaten')">Grunddaten</button>
|
||||||
|
<button class="tab-btn" data-tab="admins" onclick="switchTab('admins')">Administrator*Innen</button>
|
||||||
|
<button class="tab-btn" data-tab="posteingang" onclick="switchTab('posteingang')">Posteingang</button>
|
||||||
|
<button class="tab-btn" data-tab="veranstaltungen" onclick="switchTab('veranstaltungen')">Veranstaltungen</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-panel" id="tab-grunddaten">
|
||||||
|
${locHeaderHtml}
|
||||||
|
${hoursHtml}
|
||||||
|
${gallerySection}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-panel" id="tab-admins">
|
||||||
|
<div id="adminList" style="display:flex;flex-direction:column;gap:0.5rem;margin-bottom:0.75rem;"></div>
|
||||||
|
<div style="display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap;">
|
||||||
|
<div style="flex:1;min-width:180px;">
|
||||||
|
<input type="text" id="adminSearchInput" placeholder="Mitglied suchen…" autocomplete="off"
|
||||||
|
oninput="onAdminSearch()" style="width:100%;box-sizing:border-box;">
|
||||||
|
</div>
|
||||||
|
<button class="btn" style="font-size:0.85rem;white-space:nowrap;" onclick="addAdminFromSearch()">+ Admin hinzufügen</button>
|
||||||
|
</div>
|
||||||
|
${isOwner ? `
|
||||||
|
<div style="margin-top:1rem;padding-top:1rem;border-top:1px solid var(--color-secondary);">
|
||||||
|
<div style="font-size:0.82rem;color:var(--color-muted);margin-bottom:0.5rem;">Inhaberrechte übertragen</div>
|
||||||
|
<div style="display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap;">
|
||||||
|
<div style="flex:1;min-width:180px;">
|
||||||
|
<input type="text" id="ownerSearchInput" placeholder="Mitglied suchen…" autocomplete="off"
|
||||||
|
oninput="onOwnerSearch()" style="width:100%;box-sizing:border-box;">
|
||||||
|
</div>
|
||||||
|
<button class="btn" style="font-size:0.85rem;white-space:nowrap;background:#c0392b;" onclick="transferOwner()">Inhaberwechsel</button>
|
||||||
|
</div>
|
||||||
|
</div>` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-panel" id="tab-posteingang">
|
||||||
|
<div id="inboxList" class="inbox-list"><p style="color:var(--color-muted);font-size:0.9rem;">Wird geladen…</p></div>
|
||||||
|
<div class="inbox-chat" id="inboxChat">
|
||||||
|
<div class="inbox-chat-header">
|
||||||
|
<button class="inbox-chat-back" onclick="closeInboxChat()" aria-label="Zurück">‹</button>
|
||||||
|
<span id="inboxChatName" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></span>
|
||||||
|
<button class="inbox-chat-close" onclick="closeInboxChat()" aria-label="Schließen">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="inbox-chat-messages" id="inboxChatMessages"></div>
|
||||||
|
<div class="inbox-lock-hint" id="inboxLockHint" style="display:none;"></div>
|
||||||
|
<div class="inbox-reply-trigger" id="inboxReplyTrigger" style="display:none;">
|
||||||
|
<button class="btn" onclick="startReplying()">✎ Antworten</button>
|
||||||
|
</div>
|
||||||
|
<div class="inbox-reply-area" id="inboxReplyArea" style="display:none;">
|
||||||
|
<input type="text" id="inboxReplyInput" placeholder="Antwort eingeben…" autocomplete="off"
|
||||||
|
onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendInboxReply();}">
|
||||||
|
<button class="btn inbox-reply-btn" onclick="sendInboxReply()">↑</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-panel" id="tab-veranstaltungen">
|
||||||
|
${eventsSection}
|
||||||
|
</div>`;
|
||||||
|
} else {
|
||||||
|
document.getElementById('content').innerHTML = `
|
||||||
|
${locHeaderHtml}
|
||||||
|
${hoursHtml}
|
||||||
|
${gallerySection}
|
||||||
|
${eventsSection}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildGalleryHtml(gallery) {
|
function buildGalleryHtml(gallery) {
|
||||||
@@ -298,7 +473,7 @@ async function uploadGalleryImage(input) {
|
|||||||
headers: {'Content-Type':'application/json'},
|
headers: {'Content-Type':'application/json'},
|
||||||
body: JSON.stringify({ imageData })
|
body: JSON.stringify({ imageData })
|
||||||
});
|
});
|
||||||
if (res.status === 422) { alert('Maximal 20 Galeriebilder erlaubt.'); return; }
|
if (res.status === 422) { showAlert('Maximal 20 Galeriebilder erlaubt.'); return; }
|
||||||
if (!res.ok) throw new Error();
|
if (!res.ok) throw new Error();
|
||||||
const img = await res.json();
|
const img = await res.json();
|
||||||
const grid = document.getElementById('galleryGrid');
|
const grid = document.getElementById('galleryGrid');
|
||||||
@@ -307,18 +482,34 @@ async function uploadGalleryImage(input) {
|
|||||||
<img src="data:image/jpeg;base64,${img.imageData}" alt="Galeriebild" onclick="openLightbox(this.src)">
|
<img src="data:image/jpeg;base64,${img.imageData}" alt="Galeriebild" onclick="openLightbox(this.src)">
|
||||||
<button class="gallery-del-btn" onclick="deleteGalleryImage('${img.imageId}')">✕</button>
|
<button class="gallery-del-btn" onclick="deleteGalleryImage('${img.imageId}')">✕</button>
|
||||||
</div>`);
|
</div>`);
|
||||||
} catch { alert('Fehler beim Hochladen.'); }
|
} catch { showAlert('Fehler beim Hochladen.'); }
|
||||||
input.value = '';
|
input.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteGalleryImage(imageId) {
|
async function deleteGalleryImage(imageId) {
|
||||||
if (!confirm('Bild löschen?')) return;
|
if (!confirm('Bild löschen?')) return;
|
||||||
const res = await fetch(`/locations/${locationId}/gallery/${imageId}`, { method: 'DELETE' });
|
const res = await fetch(`/locations/${locationId}/gallery/${imageId}`, { method: 'DELETE' });
|
||||||
if (!res.ok) { alert('Fehler beim Löschen.'); return; }
|
if (!res.ok) { showAlert('Fehler beim Löschen.'); return; }
|
||||||
await loadPage();
|
await loadPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Events ─────────────────────────────────────────────────────────────────────
|
// ── Events ─────────────────────────────────────────────────────────────────────
|
||||||
|
function buildEventCard(e, isPast) {
|
||||||
|
const imgHtml = e.imageData
|
||||||
|
? `<img src="data:image/jpeg;base64,${e.imageData}" alt="${escHtml(e.title)}">`
|
||||||
|
: '🗓';
|
||||||
|
const opacity = isPast ? 'opacity:0.6;' : '';
|
||||||
|
return `
|
||||||
|
<a class="event-card" href="/community/event-detail.html?id=${e.eventId}" style="${opacity}">
|
||||||
|
<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>`;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadEvents() {
|
async function loadEvents() {
|
||||||
const res = await fetch(`/locations/${locationId}/events`);
|
const res = await fetch(`/locations/${locationId}/events`);
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
@@ -326,37 +517,50 @@ async function loadEvents() {
|
|||||||
const list = document.getElementById('eventList');
|
const list = document.getElementById('eventList');
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
|
|
||||||
if (events.length === 0) {
|
const now = new Date();
|
||||||
list.innerHTML = '<p style="color:var(--color-muted);font-size:0.9rem;">Noch keine Veranstaltungen.</p>';
|
const future = events.filter(e => new Date(e.startAt) >= now);
|
||||||
return;
|
const past = events.filter(e => new Date(e.startAt) < now)
|
||||||
}
|
.slice(-5) // letzte 5
|
||||||
|
.reverse(); // neueste zuerst
|
||||||
|
|
||||||
list.innerHTML = events.map(e => {
|
list.innerHTML = future.length
|
||||||
const imgHtml = e.imageData
|
? future.map(e => buildEventCard(e, false)).join('')
|
||||||
? `<img src="data:image/jpeg;base64,${e.imageData}" alt="${escHtml(e.title)}">`
|
: '<p style="color:var(--color-muted);font-size:0.9rem;">Keine bevorstehenden Veranstaltungen.</p>';
|
||||||
: '🗓';
|
|
||||||
const deleteBtn = isOwner
|
const pastSection = document.getElementById('pastEventsSection');
|
||||||
? `<button class="btn" style="font-size:0.75rem;margin-top:0.3rem;background:var(--color-secondary);color:var(--color-text);padding:0.2rem 0.5rem;" onclick="event.preventDefault();deleteEvent('${e.eventId}')">Löschen</button>`
|
if (past.length && pastSection) {
|
||||||
: '';
|
document.getElementById('pastEventList').innerHTML = past.map(e => buildEventCard(e, true)).join('');
|
||||||
return `
|
pastSection.style.display = '';
|
||||||
<a class="event-card" href="/community/event-detail.html?id=${e.eventId}">
|
} else if (pastSection) {
|
||||||
<div class="event-card-img">${imgHtml}</div>
|
pastSection.style.display = 'none';
|
||||||
<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>
|
|
||||||
${deleteBtn}
|
|
||||||
</div>
|
|
||||||
</a>`;
|
|
||||||
}).join('');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Lightbox ───────────────────────────────────────────────────────────────────
|
// ── Lightbox ───────────────────────────────────────────────────────────────────
|
||||||
|
let lbSrcs = [], lbIdx = 0;
|
||||||
|
|
||||||
function openLightbox(src) {
|
function openLightbox(src) {
|
||||||
document.getElementById('lbImg').src = src;
|
lbSrcs = Array.from(document.querySelectorAll('#galleryGrid .gallery-img-wrap img')).map(i => i.src);
|
||||||
|
lbIdx = Math.max(0, lbSrcs.indexOf(src));
|
||||||
|
lbShow();
|
||||||
document.getElementById('lightbox').classList.add('open');
|
document.getElementById('lightbox').classList.add('open');
|
||||||
}
|
}
|
||||||
|
function lbShow() {
|
||||||
|
document.getElementById('lbImg').src = lbSrcs[lbIdx];
|
||||||
|
document.getElementById('lbPrev').disabled = lbIdx === 0;
|
||||||
|
document.getElementById('lbNext').disabled = lbIdx === lbSrcs.length - 1;
|
||||||
|
}
|
||||||
|
function lbNav(dir) {
|
||||||
|
lbIdx = Math.max(0, Math.min(lbSrcs.length - 1, lbIdx + dir));
|
||||||
|
lbShow();
|
||||||
|
}
|
||||||
function closeLightbox() { document.getElementById('lightbox').classList.remove('open'); }
|
function closeLightbox() { document.getElementById('lightbox').classList.remove('open'); }
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (!document.getElementById('lightbox').classList.contains('open')) return;
|
||||||
|
if (e.key === 'Escape') closeLightbox();
|
||||||
|
else if (e.key === 'ArrowLeft') lbNav(-1);
|
||||||
|
else if (e.key === 'ArrowRight') lbNav(1);
|
||||||
|
});
|
||||||
|
|
||||||
// ── Edit Modal ─────────────────────────────────────────────────────────────────
|
// ── Edit Modal ─────────────────────────────────────────────────────────────────
|
||||||
let _editLq = null, _editHq = null, _editLat = null, _editLon = null, _editStreet = null, _editCity = null, _editCityTimer = null;
|
let _editLq = null, _editHq = null, _editLat = null, _editLon = null, _editStreet = null, _editCity = null, _editCityTimer = null;
|
||||||
@@ -489,7 +693,7 @@ document.addEventListener('click', e => {
|
|||||||
|
|
||||||
async function submitEdit() {
|
async function submitEdit() {
|
||||||
const name = document.getElementById('editName').value.trim();
|
const name = document.getElementById('editName').value.trim();
|
||||||
if (!name) { alert('Name darf nicht leer sein.'); return; }
|
if (!name) { showAlert('Name darf nicht leer sein.'); return; }
|
||||||
const addrVal = document.getElementById('editCity').value.trim();
|
const addrVal = document.getElementById('editCity').value.trim();
|
||||||
if (addrVal && _editLat == null) {
|
if (addrVal && _editLat == null) {
|
||||||
document.getElementById('editLocMsg').textContent = 'Bitte eine Adresse aus der Vorschlagsliste auswählen oder per GPS ermitteln.';
|
document.getElementById('editLocMsg').textContent = 'Bitte eine Adresse aus der Vorschlagsliste auswählen oder per GPS ermitteln.';
|
||||||
@@ -530,7 +734,7 @@ async function submitEdit() {
|
|||||||
closeEditModal();
|
closeEditModal();
|
||||||
renderPage();
|
renderPage();
|
||||||
loadEvents();
|
loadEvents();
|
||||||
} catch { alert('Fehler beim Speichern.'); }
|
} catch { showAlert('Fehler beim Speichern.'); }
|
||||||
finally { btn.disabled = false; btn.textContent = 'Speichern'; }
|
finally { btn.disabled = false; btn.textContent = 'Speichern'; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -547,14 +751,14 @@ async function toggleFollow() {
|
|||||||
btn.style.background = isFollowing ? 'var(--color-primary)' : 'var(--color-secondary)';
|
btn.style.background = isFollowing ? 'var(--color-primary)' : 'var(--color-secondary)';
|
||||||
btn.style.color = isFollowing ? '#fff' : 'var(--color-text)';
|
btn.style.color = isFollowing ? '#fff' : 'var(--color-text)';
|
||||||
}
|
}
|
||||||
} catch (_) { alert('Fehler beim Aktualisieren des Abonnements.'); }
|
} catch (_) { showAlert('Fehler beim Aktualisieren des Abonnements.'); }
|
||||||
finally { if (btn) btn.disabled = false; }
|
finally { if (btn) btn.disabled = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteLocation() {
|
async function deleteLocation() {
|
||||||
if (!confirm('Location wirklich löschen? Alle Veranstaltungen und Galeriebilder werden ebenfalls gelöscht.')) return;
|
if (!confirm('Location wirklich löschen? Alle Veranstaltungen und Galeriebilder werden ebenfalls gelöscht.')) return;
|
||||||
const res = await fetch(`/locations/${locationId}`, { method: 'DELETE' });
|
const res = await fetch(`/locations/${locationId}`, { method: 'DELETE' });
|
||||||
if (!res.ok) { alert('Fehler beim Löschen.'); return; }
|
if (!res.ok) { showAlert('Fehler beim Löschen.'); return; }
|
||||||
window.location.href = '/community/locations.html';
|
window.location.href = '/community/locations.html';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -568,6 +772,9 @@ function openEventModal(evtId) {
|
|||||||
document.getElementById('eventTitle').value = '';
|
document.getElementById('eventTitle').value = '';
|
||||||
document.getElementById('eventDesc').value = '';
|
document.getElementById('eventDesc').value = '';
|
||||||
document.getElementById('eventStartAt').value = '';
|
document.getElementById('eventStartAt').value = '';
|
||||||
|
// Nur Termine in der Zukunft erlauben
|
||||||
|
const nowLocal = new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().slice(0, 16);
|
||||||
|
document.getElementById('eventStartAt').min = nowLocal;
|
||||||
document.getElementById('eventPicPreview').innerHTML = '🗓';
|
document.getElementById('eventPicPreview').innerHTML = '🗓';
|
||||||
document.getElementById('eventModal').classList.add('open');
|
document.getElementById('eventModal').classList.add('open');
|
||||||
}
|
}
|
||||||
@@ -582,8 +789,9 @@ async function onEventPicChange(input) {
|
|||||||
async function submitEvent() {
|
async function submitEvent() {
|
||||||
const title = document.getElementById('eventTitle').value.trim();
|
const title = document.getElementById('eventTitle').value.trim();
|
||||||
const startAt = document.getElementById('eventStartAt').value;
|
const startAt = document.getElementById('eventStartAt').value;
|
||||||
if (!title) { alert('Bitte gib einen Titel ein.'); return; }
|
if (!title) { showAlert('Bitte gib einen Titel ein.'); return; }
|
||||||
if (!startAt) { alert('Bitte wähle Datum und Uhrzeit.'); return; }
|
if (!startAt) { showAlert('Bitte wähle Datum und Uhrzeit.'); return; }
|
||||||
|
if (new Date(startAt) <= new Date()) { showAlert('Der Termin muss in der Zukunft liegen.'); return; }
|
||||||
|
|
||||||
const btn = document.getElementById('eventSubmitBtn');
|
const btn = document.getElementById('eventSubmitBtn');
|
||||||
btn.disabled = true; btn.textContent = 'Wird gespeichert…';
|
btn.disabled = true; btn.textContent = 'Wird gespeichert…';
|
||||||
@@ -610,15 +818,321 @@ async function submitEvent() {
|
|||||||
|
|
||||||
closeEventModal();
|
closeEventModal();
|
||||||
loadEvents();
|
loadEvents();
|
||||||
} catch { alert('Fehler beim Speichern.'); }
|
} catch { showAlert('Fehler beim Speichern.'); }
|
||||||
finally { btn.disabled = false; btn.textContent = 'Speichern'; }
|
finally { btn.disabled = false; btn.textContent = 'Speichern'; }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteEvent(eventId) {
|
function showAlert(message) {
|
||||||
if (!confirm('Veranstaltung löschen?')) return;
|
document.getElementById('alertMessage').textContent = message;
|
||||||
const res = await fetch(`/locations/${locationId}/events/${eventId}`, { method: 'DELETE' });
|
document.getElementById('alertModal').classList.add('open');
|
||||||
if (!res.ok) { alert('Fehler beim Löschen.'); return; }
|
}
|
||||||
loadEvents();
|
|
||||||
|
function openConfirm(title, message, onOk) {
|
||||||
|
document.getElementById('confirmTitle').textContent = title;
|
||||||
|
document.getElementById('confirmMessage').textContent = message;
|
||||||
|
const btn = document.getElementById('confirmOkBtn');
|
||||||
|
btn.onclick = () => { closeConfirm(); onOk(); };
|
||||||
|
document.getElementById('confirmModal').classList.add('open');
|
||||||
|
}
|
||||||
|
function closeConfirm() { document.getElementById('confirmModal').classList.remove('open'); }
|
||||||
|
|
||||||
|
// ── Admin-Verwaltung ──────────────────────────────────────────────────────────
|
||||||
|
let _adminSearchSelected = null;
|
||||||
|
let _ownerSearchSelected = null;
|
||||||
|
let _adminSearchTimer = null;
|
||||||
|
let _ownerSearchTimer = null;
|
||||||
|
|
||||||
|
function renderAdminList() {
|
||||||
|
const list = document.getElementById('adminList');
|
||||||
|
if (!list || !locDetail.admins) return;
|
||||||
|
list.innerHTML = locDetail.admins.map(a => {
|
||||||
|
const pic = a.profilePicture
|
||||||
|
? `<img src="data:image/jpeg;base64,${a.profilePicture}" alt="" style="width:100%;height:100%;object-fit:cover;border-radius:50%;">`
|
||||||
|
: '◉';
|
||||||
|
const badge = a.isOwner
|
||||||
|
? `<span style="font-size:0.7rem;background:var(--color-primary);color:#fff;border-radius:4px;padding:0.1rem 0.4rem;margin-left:0.4rem;">Inhaber</span>`
|
||||||
|
: '';
|
||||||
|
const removeBtn = isAdmin && !a.isOwner
|
||||||
|
? `<button onclick="removeAdmin('${a.userId}')" style="margin-left:auto;background:none;border:none;color:var(--color-muted);cursor:pointer;font-size:1rem;padding:0.2rem 0.4rem;" title="Entfernen">✕</button>`
|
||||||
|
: '';
|
||||||
|
return `<div style="display:flex;align-items:center;gap:0.6rem;padding:0.4rem 0;">
|
||||||
|
<div style="width:32px;height:32px;border-radius:50%;background:var(--color-secondary);display:flex;align-items:center;justify-content:center;font-size:1rem;overflow:hidden;flex-shrink:0;">${pic}</div>
|
||||||
|
<span style="font-size:0.9rem;">${escHtml(a.name)}</span>${badge}${removeBtn}
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeAdmin(userId) {
|
||||||
|
const res = await fetch(`/locations/${locationId}/admins/${userId}`, { method: 'DELETE' });
|
||||||
|
if (!res.ok) { showAlert('Fehler beim Entfernen.'); return; }
|
||||||
|
locDetail.admins = locDetail.admins.filter(a => a.userId !== userId);
|
||||||
|
renderAdminList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAdminSearch() {
|
||||||
|
const q = document.getElementById('adminSearchInput').value.trim();
|
||||||
|
_adminSearchSelected = null;
|
||||||
|
clearTimeout(_adminSearchTimer);
|
||||||
|
if (q.length < 2) { document.getElementById('adminSearchList').style.display = 'none'; return; }
|
||||||
|
_adminSearchTimer = setTimeout(() => fetchUserSuggestions(q, 'adminSearchList', sel => { _adminSearchSelected = sel; }), 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onOwnerSearch() {
|
||||||
|
const q = document.getElementById('ownerSearchInput').value.trim();
|
||||||
|
_ownerSearchSelected = null;
|
||||||
|
clearTimeout(_ownerSearchTimer);
|
||||||
|
if (q.length < 2) { document.getElementById('ownerSearchList').style.display = 'none'; return; }
|
||||||
|
_ownerSearchTimer = setTimeout(() => fetchUserSuggestions(q, 'ownerSearchList', sel => { _ownerSearchSelected = sel; }), 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
const _userCache = {};
|
||||||
|
|
||||||
|
async function fetchUserSuggestions(q, listId, onSelect) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/social/users/search?q=${encodeURIComponent(q)}`);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const users = await res.json();
|
||||||
|
const ul = document.getElementById(listId);
|
||||||
|
if (!users.length) { ul.style.display = 'none'; return; }
|
||||||
|
users.forEach(u => { _userCache[u.userId] = u; });
|
||||||
|
ul.innerHTML = users.map(u => `
|
||||||
|
<li style="padding:0.45rem 0.75rem;cursor:pointer;font-size:0.9rem;border-bottom:1px solid var(--color-secondary);"
|
||||||
|
onmousedown="event.preventDefault();selectUserSuggestion('${listId}','${u.userId}')">
|
||||||
|
${escHtml(u.name)}
|
||||||
|
</li>`).join('');
|
||||||
|
ul.__selectFn = onSelect;
|
||||||
|
// Dropdown unter dem zugehörigen Input positionieren
|
||||||
|
const inputId = listId === 'adminSearchList' ? 'adminSearchInput' : 'ownerSearchInput';
|
||||||
|
const rect = document.getElementById(inputId).getBoundingClientRect();
|
||||||
|
ul.style.top = (rect.bottom + 2) + 'px';
|
||||||
|
ul.style.left = rect.left + 'px';
|
||||||
|
ul.style.width = rect.width + 'px';
|
||||||
|
ul.style.display = '';
|
||||||
|
} catch(_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectUserSuggestion(listId, userId) {
|
||||||
|
const ul = document.getElementById(listId);
|
||||||
|
const inputId = listId === 'adminSearchList' ? 'adminSearchInput' : 'ownerSearchInput';
|
||||||
|
const user = _userCache[userId];
|
||||||
|
if (!user) return;
|
||||||
|
document.getElementById(inputId).value = user.name;
|
||||||
|
ul.style.display = 'none';
|
||||||
|
if (ul.__selectFn) ul.__selectFn(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
['adminSearchList','ownerSearchList'].forEach(id => {
|
||||||
|
const ul = document.getElementById(id);
|
||||||
|
if (ul && !e.target.closest('#' + id) && e.target.id !== (id === 'adminSearchList' ? 'adminSearchInput' : 'ownerSearchInput'))
|
||||||
|
ul.style.display = 'none';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function addAdminFromSearch() {
|
||||||
|
if (!_adminSearchSelected) { showAlert('Bitte wähle ein Mitglied aus der Liste.'); return; }
|
||||||
|
const res = await fetch(`/locations/${locationId}/admins`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({ userId: _adminSearchSelected.userId })
|
||||||
|
});
|
||||||
|
if (res.status === 409) { showAlert('Diese Person ist bereits Admin.'); return; }
|
||||||
|
if (!res.ok) { showAlert('Fehler beim Hinzufügen.'); return; }
|
||||||
|
const added = await res.json();
|
||||||
|
locDetail.admins = [...(locDetail.admins || []), added];
|
||||||
|
renderAdminList();
|
||||||
|
document.getElementById('adminSearchInput').value = '';
|
||||||
|
_adminSearchSelected = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function transferOwner() {
|
||||||
|
if (!_ownerSearchSelected) { showAlert('Bitte wähle ein Mitglied aus der Liste.'); return; }
|
||||||
|
openConfirm(
|
||||||
|
'Inhaberwechsel',
|
||||||
|
`Inhaberrechte wirklich an „${_ownerSearchSelected.name}" übertragen? Diese Aktion kann nicht rückgängig gemacht werden.`,
|
||||||
|
async () => {
|
||||||
|
const res = await fetch(`/locations/${locationId}/admins/transfer-owner`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({ userId: _ownerSearchSelected.userId })
|
||||||
|
});
|
||||||
|
if (!res.ok) { showAlert('Fehler beim Inhaberwechsel.'); return; }
|
||||||
|
// Seite neu laden – aktueller User ist jetzt kein Inhaber mehr
|
||||||
|
await loadPage();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Kontaktieren ──────────────────────────────────────────────────────────────
|
||||||
|
function contactLocation(virtualUserId) {
|
||||||
|
window.location.href = '/community/nachrichten.html?partnerId=' + virtualUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Posteingang (Admin) ───────────────────────────────────────────────────────
|
||||||
|
let _inboxPartnerId = null;
|
||||||
|
|
||||||
|
async function loadInbox() {
|
||||||
|
const el = document.getElementById('inboxList');
|
||||||
|
if (!el) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/locations/${locationId}/inbox`);
|
||||||
|
if (!res.ok) { el.innerHTML = '<p style="color:var(--color-muted);font-size:0.9rem;">Nicht verfügbar.</p>'; return; }
|
||||||
|
const items = await res.json();
|
||||||
|
if (items.length === 0) {
|
||||||
|
el.innerHTML = '<p style="color:var(--color-muted);font-size:0.9rem;">Noch keine Nachrichten.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = items.map(item => {
|
||||||
|
const av = item.senderPicture
|
||||||
|
? `<img src="data:image/png;base64,${item.senderPicture}" alt="">`
|
||||||
|
: '◉';
|
||||||
|
const unreadHtml = item.unreadCount > 0
|
||||||
|
? `<span class="inbox-unread">${item.unreadCount}</span>` : '';
|
||||||
|
return `<div class="inbox-item" onclick="openInboxChat('${item.senderId}','${escHtml(item.senderName)}')">
|
||||||
|
<div class="inbox-avatar">${av}</div>
|
||||||
|
<div class="inbox-info">
|
||||||
|
<div class="inbox-name">${escHtml(item.senderName)}</div>
|
||||||
|
<div class="inbox-preview">${escHtml(item.lastMessage)}</div>
|
||||||
|
</div>
|
||||||
|
${unreadHtml}
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// URL-Parameter: Chat direkt öffnen (z.B. nach Klick auf Benachrichtigung)
|
||||||
|
const chatWithId = new URLSearchParams(location.search).get('chatWith');
|
||||||
|
if (chatWithId && !_inboxPartnerId) {
|
||||||
|
const match = items.find(i => i.senderId === chatWithId);
|
||||||
|
if (match) openInboxChat(match.senderId, match.senderName);
|
||||||
|
}
|
||||||
|
} catch { el.innerHTML = '<p style="color:var(--color-muted);font-size:0.9rem;">Fehler beim Laden.</p>'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openInboxChat(partnerId, partnerName) {
|
||||||
|
_inboxPartnerId = partnerId;
|
||||||
|
document.getElementById('inboxChatName').textContent = partnerName;
|
||||||
|
document.getElementById('inboxChatMessages').innerHTML = '<p style="color:var(--color-muted);font-size:0.85rem;text-align:center;">Wird geladen…</p>';
|
||||||
|
document.getElementById('inboxChat').classList.add('open');
|
||||||
|
document.getElementById('inboxReplyInput').value = '';
|
||||||
|
// Eingabebereich zurücksetzen
|
||||||
|
document.getElementById('inboxReplyTrigger').style.display = 'none';
|
||||||
|
document.getElementById('inboxReplyArea').style.display = 'none';
|
||||||
|
document.getElementById('inboxLockHint').style.display = 'none';
|
||||||
|
|
||||||
|
const virtualId = locDetail.virtualUserId;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/locations/${locationId}/inbox/${partnerId}`);
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
const data = await res.json();
|
||||||
|
const messages = data.messages || [];
|
||||||
|
|
||||||
|
const container = document.getElementById('inboxChatMessages');
|
||||||
|
container.innerHTML = '';
|
||||||
|
if (messages.length === 0) {
|
||||||
|
container.innerHTML = '<p style="color:var(--color-muted);font-size:0.85rem;text-align:center;">Noch keine Nachrichten.</p>';
|
||||||
|
} else {
|
||||||
|
messages.forEach(m => {
|
||||||
|
const isLocationSender = m.senderId === virtualId;
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'inbox-bubble-wrap ' + (isLocationSender ? 'me' : 'them');
|
||||||
|
const time = new Date(m.sentAt).toLocaleTimeString('de', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
wrap.innerHTML = `<div class="inbox-bubble">${escHtml(m.text)}</div><div class="inbox-bubble-time">${time}</div>`;
|
||||||
|
container.appendChild(wrap);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
requestAnimationFrame(() => { container.scrollTop = container.scrollHeight; });
|
||||||
|
|
||||||
|
// Lock-Status anzeigen
|
||||||
|
applyLockUi(data);
|
||||||
|
loadInbox();
|
||||||
|
} catch {
|
||||||
|
document.getElementById('inboxChatMessages').innerHTML = '<p style="color:var(--color-muted);font-size:0.85rem;text-align:center;">Fehler beim Laden.</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLockUi(data) {
|
||||||
|
const trigger = document.getElementById('inboxReplyTrigger');
|
||||||
|
const area = document.getElementById('inboxReplyArea');
|
||||||
|
const lockHint = document.getElementById('inboxLockHint');
|
||||||
|
|
||||||
|
if (data.lockedByMe) {
|
||||||
|
// Ich habe die Sperre → Eingabefeld direkt zeigen
|
||||||
|
trigger.style.display = 'none';
|
||||||
|
area.style.display = '';
|
||||||
|
lockHint.style.display = 'none';
|
||||||
|
document.getElementById('inboxReplyInput').focus();
|
||||||
|
} else if (data.canReply) {
|
||||||
|
// Frei → "Antworten"-Button zeigen
|
||||||
|
trigger.style.display = '';
|
||||||
|
area.style.display = 'none';
|
||||||
|
lockHint.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
// Gesperrt durch anderen Admin
|
||||||
|
trigger.style.display = 'none';
|
||||||
|
area.style.display = 'none';
|
||||||
|
lockHint.style.display = '';
|
||||||
|
lockHint.textContent = `Wird gerade von ${data.lockedByName || 'einem anderen Admin'} beantwortet.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startReplying() {
|
||||||
|
if (!_inboxPartnerId) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/locations/${locationId}/inbox/${_inboxPartnerId}/lock`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
if (res.status === 409) {
|
||||||
|
const body = await res.json();
|
||||||
|
const name = body.lockedByName || 'einem anderen Admin';
|
||||||
|
document.getElementById('inboxReplyTrigger').style.display = 'none';
|
||||||
|
document.getElementById('inboxLockHint').style.display = '';
|
||||||
|
document.getElementById('inboxLockHint').textContent =
|
||||||
|
`Wird gerade von ${name} beantwortet.`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res.ok) { showAlert('Sperre konnte nicht erworben werden.'); return; }
|
||||||
|
document.getElementById('inboxReplyTrigger').style.display = 'none';
|
||||||
|
document.getElementById('inboxReplyArea').style.display = '';
|
||||||
|
document.getElementById('inboxReplyInput').focus();
|
||||||
|
} catch { showAlert('Fehler beim Sperren.'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeInboxChat() {
|
||||||
|
_inboxPartnerId = null;
|
||||||
|
document.getElementById('inboxChat').classList.remove('open');
|
||||||
|
document.getElementById('inboxReplyTrigger').style.display = 'none';
|
||||||
|
document.getElementById('inboxReplyArea').style.display = 'none';
|
||||||
|
document.getElementById('inboxLockHint').style.display = 'none';
|
||||||
|
loadInbox();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendInboxReply() {
|
||||||
|
if (!_inboxPartnerId) return;
|
||||||
|
const input = document.getElementById('inboxReplyInput');
|
||||||
|
const text = input.value.trim();
|
||||||
|
if (!text) return;
|
||||||
|
input.value = '';
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/locations/${locationId}/inbox/${_inboxPartnerId}/reply`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ text })
|
||||||
|
});
|
||||||
|
if (res.status === 409) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
const name = body.lockedByName || 'einem anderen Admin';
|
||||||
|
input.value = text;
|
||||||
|
// Eingabe sperren, Hinweis zeigen
|
||||||
|
document.getElementById('inboxReplyArea').style.display = 'none';
|
||||||
|
document.getElementById('inboxLockHint').style.display = '';
|
||||||
|
document.getElementById('inboxLockHint').textContent =
|
||||||
|
`Wird gerade von ${name} beantwortet. Deine Nachricht wurde nicht gesendet.`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res.ok) { showAlert('Fehler beim Senden.'); input.value = text; return; }
|
||||||
|
// Konversation neu laden (Lock ist nach Senden bei mir)
|
||||||
|
const partnerName = document.getElementById('inboxChatName').textContent;
|
||||||
|
await openInboxChat(_inboxPartnerId, partnerName);
|
||||||
|
} catch { showAlert('Fehler beim Senden.'); input.value = text; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Init ──────────────────────────────────────────────────────────────────────
|
// ── Init ──────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -121,6 +121,8 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span class="filter-badge" id="filterBadge" style="display:none;position:absolute;top:-3px;right:-3px;"></span>
|
<span class="filter-badge" id="filterBadge" style="display:none;position:absolute;top:-3px;right:-3px;"></span>
|
||||||
</button>
|
</button>
|
||||||
|
<button id="createLocBtn" class="btn" onclick="openCreateModal()"
|
||||||
|
style="display:none;padding:0.35rem 0.85rem;font-size:0.85rem;">+ Location anlegen</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -134,9 +136,6 @@
|
|||||||
|
|
||||||
<!-- ── Meine Locations ──────────────────────────────────────────── -->
|
<!-- ── Meine Locations ──────────────────────────────────────────── -->
|
||||||
<div id="paneMine" class="tab-panel">
|
<div id="paneMine" class="tab-panel">
|
||||||
<div style="display:flex; justify-content:flex-end; margin-bottom:1rem;">
|
|
||||||
<button class="btn" onclick="openCreateModal()">+ Location anlegen</button>
|
|
||||||
</div>
|
|
||||||
<div class="loc-grid" id="mineGrid"></div>
|
<div class="loc-grid" id="mineGrid"></div>
|
||||||
<p class="empty-hint" id="mineEmpty" style="display:none;">Du hast noch keine Locations angelegt.</p>
|
<p class="empty-hint" id="mineEmpty" style="display:none;">Du hast noch keine Locations angelegt.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -228,6 +227,8 @@ function switchTab(name, btn) {
|
|||||||
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
||||||
btn.classList.add('active');
|
btn.classList.add('active');
|
||||||
document.getElementById('pane' + name.charAt(0).toUpperCase() + name.slice(1)).classList.add('active');
|
document.getElementById('pane' + name.charAt(0).toUpperCase() + name.slice(1)).classList.add('active');
|
||||||
|
document.getElementById('filterOpenBtn').style.display = name === 'search' ? '' : 'none';
|
||||||
|
document.getElementById('createLocBtn').style.display = name === 'mine' ? '' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Filter-Drawer ─────────────────────────────────────────────────────────────
|
// ── Filter-Drawer ─────────────────────────────────────────────────────────────
|
||||||
@@ -378,8 +379,8 @@ async function loadNextBatch() {
|
|||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.className = 'loc-card';
|
a.className = 'loc-card';
|
||||||
a.href = `/community/location-detail.html?id=${p.locationId}`;
|
a.href = `/community/location-detail.html?id=${p.locationId}`;
|
||||||
const imgHtml = p.profilePictureLq
|
const imgHtml = p.profilePictureHq
|
||||||
? `<img src="data:image/jpeg;base64,${p.profilePictureLq}" alt="${escHtml(p.name)}">`
|
? `<img src="data:image/jpeg;base64,${p.profilePictureHq}" alt="${escHtml(p.name)}">`
|
||||||
: '<span>📍</span>';
|
: '<span>📍</span>';
|
||||||
a.innerHTML = `
|
a.innerHTML = `
|
||||||
<div class="loc-card-img">${imgHtml}</div>
|
<div class="loc-card-img">${imgHtml}</div>
|
||||||
@@ -421,8 +422,8 @@ async function loadMine() {
|
|||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.className = 'loc-card';
|
a.className = 'loc-card';
|
||||||
a.href = `/community/location-detail.html?id=${p.locationId}`;
|
a.href = `/community/location-detail.html?id=${p.locationId}`;
|
||||||
const imgHtml = p.profilePictureLq
|
const imgHtml = p.profilePictureHq
|
||||||
? `<img src="data:image/jpeg;base64,${p.profilePictureLq}" alt="${escHtml(p.name)}">`
|
? `<img src="data:image/jpeg;base64,${p.profilePictureHq}" alt="${escHtml(p.name)}">`
|
||||||
: '<span>📍</span>';
|
: '<span>📍</span>';
|
||||||
a.innerHTML = `
|
a.innerHTML = `
|
||||||
<div class="loc-card-img">${imgHtml}</div>
|
<div class="loc-card-img">${imgHtml}</div>
|
||||||
|
|||||||
@@ -341,7 +341,8 @@
|
|||||||
if (!user) return;
|
if (!user) return;
|
||||||
myId = user.userId;
|
myId = user.userId;
|
||||||
loadConversations();
|
loadConversations();
|
||||||
const urlPartnerId = new URLSearchParams(window.location.search).get('userId');
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const urlPartnerId = params.get('partnerId') || params.get('userId');
|
||||||
if (urlPartnerId) openThread(urlPartnerId);
|
if (urlPartnerId) openThread(urlPartnerId);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
@@ -363,31 +364,36 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
convs.forEach(c => {
|
convs.forEach(c => {
|
||||||
|
const isLocation = c.partner.friendStatus === 'LOCATION';
|
||||||
const av = c.partner.profilePicture
|
const av = c.partner.profilePicture
|
||||||
? `<img src="data:image/png;base64,${c.partner.profilePicture}" alt="" style="cursor:zoom-in;" onclick="event.stopPropagation();openLightbox(this.src)">`
|
? `<img src="data:image/png;base64,${c.partner.profilePicture}" alt="" style="cursor:zoom-in;" onclick="event.stopPropagation();openLightbox(this.src)">`
|
||||||
: '◉';
|
: (isLocation ? '📍' : '◉');
|
||||||
const unreadHtml = c.unreadCount > 0
|
const unreadHtml = c.unreadCount > 0
|
||||||
? `<span class="conv-unread">${c.unreadCount}</span>`
|
? `<span class="conv-unread">${c.unreadCount}</span>`
|
||||||
: '';
|
: '';
|
||||||
const preview = c.lastMessage
|
const preview = c.lastMessage
|
||||||
? (c.lastMessage.text.startsWith('data:image/') ? '📷 Bild' : esc(c.lastMessage.text.substring(0, 40)))
|
? (c.lastMessage.text.startsWith('data:image/') ? '📷 Bild' : esc(c.lastMessage.text.substring(0, 40)))
|
||||||
: '';
|
: '';
|
||||||
|
const locationBadge = isLocation
|
||||||
|
? `<span style="font-size:0.6rem;background:var(--color-secondary);color:var(--color-muted);border-radius:4px;padding:0.1rem 0.3rem;margin-left:0.3rem;">Location</span>`
|
||||||
|
: '';
|
||||||
const li = document.createElement('li');
|
const li = document.createElement('li');
|
||||||
li.className = 'conv-item' + (c.partner.userId === activePartnerId ? ' active' : '');
|
li.className = 'conv-item' + (c.partner.userId === activePartnerId ? ' active' : '');
|
||||||
li.dataset.partnerId = c.partner.userId;
|
li.dataset.partnerId = c.partner.userId;
|
||||||
|
li.dataset.isLocation = isLocation ? '1' : '';
|
||||||
li.innerHTML = `
|
li.innerHTML = `
|
||||||
<div class="conv-avatar">${av}</div>
|
<div class="conv-avatar">${av}</div>
|
||||||
<div class="conv-info">
|
<div class="conv-info">
|
||||||
<div class="conv-name">${esc(c.partner.name)}</div>
|
<div class="conv-name">${esc(c.partner.name)}${locationBadge}</div>
|
||||||
<div class="conv-preview">${preview}</div>
|
<div class="conv-preview">${preview}</div>
|
||||||
</div>
|
</div>
|
||||||
${unreadHtml}`;
|
${unreadHtml}`;
|
||||||
li.addEventListener('click', () => openThread(c.partner.userId, c.partner.name, c.partner.profilePicture));
|
li.addEventListener('click', () => openThread(c.partner.userId, c.partner.name, c.partner.profilePicture, isLocation));
|
||||||
list.appendChild(li);
|
list.appendChild(li);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openThread(partnerId, partnerName, partnerPic) {
|
async function openThread(partnerId, partnerName, partnerPic, isLocation) {
|
||||||
activePartnerId = partnerId;
|
activePartnerId = partnerId;
|
||||||
oldestSentAt = null;
|
oldestSentAt = null;
|
||||||
newestSentAt = null;
|
newestSentAt = null;
|
||||||
@@ -398,17 +404,51 @@
|
|||||||
li.classList.toggle('active', li.dataset.partnerId === partnerId);
|
li.classList.toggle('active', li.dataset.partnerId === partnerId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// isLocation ggf. aus DOM ermitteln oder per API auflösen
|
||||||
|
if (isLocation === undefined) {
|
||||||
|
const convItem = document.querySelector(`.conv-item[data-partner-id="${partnerId}"]`);
|
||||||
|
if (convItem) {
|
||||||
|
isLocation = convItem.dataset.isLocation === '1';
|
||||||
|
} else {
|
||||||
|
// Noch kein Conv-Item (neue/unbekannte Konversation) → per API prüfen
|
||||||
|
try {
|
||||||
|
const locRes = await fetch('/locations/virtual/' + partnerId);
|
||||||
|
if (locRes.ok) {
|
||||||
|
isLocation = true;
|
||||||
|
const loc = await locRes.json();
|
||||||
|
if (!partnerName) partnerName = loc.name;
|
||||||
|
if (!partnerPic && loc.profilePictureLq) partnerPic = loc.profilePictureLq;
|
||||||
|
} else {
|
||||||
|
isLocation = false;
|
||||||
|
}
|
||||||
|
} catch { isLocation = false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!partnerName) {
|
if (!partnerName) {
|
||||||
const convItem = document.querySelector(`.conv-item[data-partner-id="${partnerId}"]`);
|
const convItem = document.querySelector(`.conv-item[data-partner-id="${partnerId}"]`);
|
||||||
partnerName = convItem ? convItem.querySelector('.conv-name').textContent : '…';
|
// .conv-name enthält ggf. das Location-Badge – nur Textinhalt nehmen
|
||||||
|
partnerName = convItem ? convItem.querySelector('.conv-name').firstChild.textContent.trim() : '…';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const locationBadge = isLocation
|
||||||
|
? ` <span style="font-size:0.65rem;background:var(--color-secondary);color:var(--color-muted);border-radius:4px;padding:0.1rem 0.35rem;vertical-align:middle;">Location</span>`
|
||||||
|
: '';
|
||||||
|
if (isLocation) {
|
||||||
|
document.getElementById('threadPartnerName').innerHTML =
|
||||||
|
`${esc(partnerName)}${locationBadge}`;
|
||||||
|
} else {
|
||||||
document.getElementById('threadPartnerName').innerHTML =
|
document.getElementById('threadPartnerName').innerHTML =
|
||||||
`<a href="/community/benutzer.html?userId=${partnerId}" style="color:inherit;text-decoration:none;">${esc(partnerName)}</a>`;
|
`<a href="/community/benutzer.html?userId=${partnerId}" style="color:inherit;text-decoration:none;">${esc(partnerName)}</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
const avatarEl = document.getElementById('threadPartnerAvatar');
|
const avatarEl = document.getElementById('threadPartnerAvatar');
|
||||||
if (partnerPic) {
|
if (partnerPic) {
|
||||||
avatarEl.innerHTML = `<img src="data:image/png;base64,${partnerPic}" alt="" style="cursor:zoom-in;" onclick="openLightbox(this.src)">`;
|
avatarEl.innerHTML = `<img src="data:image/png;base64,${partnerPic}" alt="" style="cursor:zoom-in;" onclick="openLightbox(this.src)">`;
|
||||||
avatarEl.style.display = '';
|
avatarEl.style.display = '';
|
||||||
|
} else if (isLocation) {
|
||||||
|
avatarEl.innerHTML = '📍';
|
||||||
|
avatarEl.style.display = '';
|
||||||
} else {
|
} else {
|
||||||
avatarEl.style.display = 'none';
|
avatarEl.style.display = 'none';
|
||||||
}
|
}
|
||||||
@@ -548,7 +588,12 @@
|
|||||||
input.value = text;
|
input.value = text;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// War die Konversation leer, neu laden; sonst nur neue Nachrichten pollen
|
||||||
|
if (newestSentAt) {
|
||||||
await pollNewMessages();
|
await pollNewMessages();
|
||||||
|
} else {
|
||||||
|
await loadInitialThread();
|
||||||
|
}
|
||||||
} catch (e) { console.error(e); }
|
} catch (e) { console.error(e); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -516,6 +516,17 @@ body.app {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-cat-label {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
color: var(--color-muted);
|
||||||
|
padding: 0.9rem 1.1rem 0.25rem;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-group-toggle {
|
.sidebar-group-toggle {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|||||||
@@ -44,23 +44,41 @@
|
|||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
// ── Community-Links (immer sichtbar, oberhalb der Spiele) ──
|
// ── Hilfsfunktion: einzelner Nav-Link ──
|
||||||
const socialLinks = [
|
function navLink({ href, icon, label, badgeId }) {
|
||||||
{ href: '/userhome.html', icon: I('HOME') || '⌂', label: 'Home', badgeId: null },
|
|
||||||
{ href: '/community/feed.html', icon: I('FEED'), label: 'Feed', badgeId: null },
|
|
||||||
{ href: '/community/freunde.html', icon: I('FRIENDS'), label: 'Freunde', badgeId: 'socialFriendsBadge'},
|
|
||||||
{ href: '/community/nachrichten.html', icon: I('MESSAGES'), label: 'Nachrichten', badgeId: null },
|
|
||||||
{ href: '/community/benachrichtigungen.html', icon: I('NOTIFICATIONS'), label: 'Benachrichtigungen', badgeId: null },
|
|
||||||
{ href: '/community/gruppen.html', icon: I('GROUPS'), label: 'Gruppen', badgeId: 'socialGruppenBadge'},
|
|
||||||
{ href: '/community/locations.html', icon: I('LOCATION') || '📍', label: 'Locations', badgeId: null },
|
|
||||||
{ href: '/community/events.html', icon: I('EVENT') || '🗓', label: 'Veranstaltungen', badgeId: null },
|
|
||||||
{ href: '/games/common/einladungen.html', icon: I('INVITATIONS'), label: 'Einladungen', badgeId: null },
|
|
||||||
];
|
|
||||||
const socialNav = socialLinks.map(({ href, icon, label, badgeId }) => {
|
|
||||||
const cls = path === href ? ' class="active"' : '';
|
const cls = path === href ? ' class="active"' : '';
|
||||||
const badge = badgeId ? `<span class="social-badge" id="${badgeId}" style="display:none;"></span>` : '';
|
const badge = badgeId ? `<span class="social-badge" id="${badgeId}" style="display:none;"></span>` : '';
|
||||||
return `<li><a href="${href}"${cls}><span class="icon">${icon}</span> ${label}${badge}</a></li>`;
|
return `<li><a href="${href}"${cls}><span class="icon">${icon}</span> ${label}${badge}</a></li>`;
|
||||||
}).join('');
|
}
|
||||||
|
const sep = `<li><hr style="border:none;border-top:1px solid var(--color-secondary);margin:0.4rem 1rem;"></li>`;
|
||||||
|
const catLabel = label => `<li class="sidebar-cat-label">${label}</li>`;
|
||||||
|
|
||||||
|
// Home
|
||||||
|
const homeNav = navLink({ href: '/userhome.html', icon: I('HOME') || '⌂', label: 'Home' });
|
||||||
|
|
||||||
|
// Kommunikation
|
||||||
|
const commLinks = [
|
||||||
|
{ href: '/community/nachrichten.html', icon: I('MESSAGES'), label: 'Nachrichten' },
|
||||||
|
{ href: '/community/benachrichtigungen.html', icon: I('NOTIFICATIONS'), label: 'Benachrichtigungen' },
|
||||||
|
{ href: '/games/common/einladungen.html', icon: I('INVITATIONS'), label: 'Einladungen' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Social
|
||||||
|
const socialLinks = [
|
||||||
|
{ href: '/community/feed.html', icon: I('FEED'), label: 'Feed' },
|
||||||
|
{ href: '/community/freunde.html', icon: I('FRIENDS'), label: 'Freunde', badgeId: 'socialFriendsBadge' },
|
||||||
|
{ href: '/community/gruppen.html', icon: I('GROUPS'), label: 'Gruppen', badgeId: 'socialGruppenBadge' },
|
||||||
|
{ href: '/community/locations.html', icon: I('LOCATION') || '📍', label: 'Locations' },
|
||||||
|
{ href: '/community/events.html', icon: I('EVENT') || '🗓', label: 'Veranstaltungen' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const socialNav = [
|
||||||
|
homeNav,
|
||||||
|
sep,
|
||||||
|
...commLinks.map(navLink),
|
||||||
|
sep,
|
||||||
|
...socialLinks.map(navLink),
|
||||||
|
].join('');
|
||||||
|
|
||||||
const datingActive = path === '/dating.html';
|
const datingActive = path === '/dating.html';
|
||||||
const datingCls = datingActive ? ' class="active"' : '';
|
const datingCls = datingActive ? ' class="active"' : '';
|
||||||
@@ -106,7 +124,7 @@
|
|||||||
<div class="sidebar-scroll-area">
|
<div class="sidebar-scroll-area">
|
||||||
<ul>
|
<ul>
|
||||||
${socialNav}
|
${socialNav}
|
||||||
<li><hr style="border:none;border-top:1px solid var(--color-secondary);margin:0.4rem 1rem;"></li>
|
${sep}
|
||||||
${datingItem}
|
${datingItem}
|
||||||
<li><hr style="border:none;border-top:1px solid var(--color-secondary);margin:0.4rem 1rem;"></li>
|
<li><hr style="border:none;border-top:1px solid var(--color-secondary);margin:0.4rem 1rem;"></li>
|
||||||
${nav}
|
${nav}
|
||||||
|
|||||||
@@ -107,6 +107,29 @@
|
|||||||
.match-badge {
|
.match-badge {
|
||||||
font-size: 0.65rem; color: var(--color-primary); text-align: center; font-weight: 600;
|
font-size: 0.65rem; color: var(--color-primary); text-align: center; font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Location-Events ── */
|
||||||
|
.loc-event-list { display: flex; flex-direction: column; gap: 0.6rem; }
|
||||||
|
.loc-event-card {
|
||||||
|
display: flex; gap: 0.75rem; align-items: center;
|
||||||
|
background: var(--color-secondary); border: 1px solid var(--color-secondary);
|
||||||
|
border-radius: 10px; padding: 0.65rem 0.85rem;
|
||||||
|
text-decoration: none; color: inherit;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.loc-event-card:hover { border-color: var(--color-primary); }
|
||||||
|
.loc-event-thumb {
|
||||||
|
width: 48px; height: 48px; border-radius: 8px; flex-shrink: 0;
|
||||||
|
background: var(--color-card); overflow: hidden;
|
||||||
|
display: flex; align-items: center; justify-content: center; font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
.loc-event-thumb img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
.loc-event-body { flex: 1; min-width: 0; }
|
||||||
|
.loc-event-location { font-size: 0.75rem; color: var(--color-muted); margin-bottom: 0.1rem;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.loc-event-title { font-size: 0.92rem; font-weight: 600;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.loc-event-date { font-size: 0.75rem; color: var(--color-primary); margin-top: 0.15rem; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="app">
|
<body class="app">
|
||||||
@@ -132,36 +155,17 @@
|
|||||||
<div class="section-label">Matches 💕</div>
|
<div class="section-label">Matches 💕</div>
|
||||||
<div class="dating-strip" id="matchesStrip"></div>
|
<div class="dating-strip" id="matchesStrip"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Meine angemeldeten Events -->
|
||||||
|
<div id="myEventsSection" style="display:none;">
|
||||||
|
<div class="section-label">Meine Veranstaltungen 🎟</div>
|
||||||
|
<div class="loc-event-list" id="myEventsList"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="game-grid">
|
<!-- Nächste Events abonnierter Locations -->
|
||||||
<div class="game-card">
|
<div id="locEventsSection" style="display:none;">
|
||||||
<div class="game-card-icon">❤️</div>
|
<div class="section-label">Nächste Veranstaltungen 📍</div>
|
||||||
<h2 class="game-card-title">Vanilla Game</h2>
|
<div class="loc-event-list" id="locEventsList"></div>
|
||||||
<p class="game-card-desc">
|
|
||||||
|
|
||||||
</p>
|
|
||||||
<a href="/games/vanilla/sessionvanilla.html"><button class="game-card-btn">Neue Session starten</button></a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="game-card">
|
|
||||||
<div class="game-card-icon">⛓️</div>
|
|
||||||
<h2 class="game-card-title">BDSM Game</h2>
|
|
||||||
<p class="game-card-desc">
|
|
||||||
Tauche ein in strukturierte Sessions mit Aufgaben, Toys und klaren Rollen.
|
|
||||||
Definiere Grenzen, vergib Aufgaben und erlebe intensive Momente mit deinen Spielpartner*Innen.
|
|
||||||
</p>
|
|
||||||
<a href="/games/bdsm/neubdsm.html"><button class="game-card-btn">Neue Session starten</button></a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="game-card">
|
|
||||||
<div class="game-card-icon">🔒</div>
|
|
||||||
<h2 class="game-card-title">Chastity Game</h2>
|
|
||||||
<p class="game-card-desc">
|
|
||||||
Erlebe Keuschheit auf eine neue Art: Kartenbasierte Locks, Keyholder-System,
|
|
||||||
Community-Abstimmungen und tägliche Verifizierungen machen jedes Lock einzigartig.
|
|
||||||
</p>
|
|
||||||
<a href="/games/chastity/neulock.html"><button class="game-card-btn">Neues Lock erstellen</button></a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -178,6 +182,8 @@
|
|||||||
if (user) {
|
if (user) {
|
||||||
document.getElementById('greeting').textContent = 'Willkommen zurück, ' + user.name + '!';
|
document.getElementById('greeting').textContent = 'Willkommen zurück, ' + user.name + '!';
|
||||||
loadVisitors();
|
loadVisitors();
|
||||||
|
loadMyEvents();
|
||||||
|
loadLocEvents();
|
||||||
if (user.datingAktiv) {
|
if (user.datingAktiv) {
|
||||||
loadWhoLikesMe();
|
loadWhoLikesMe();
|
||||||
loadMatches();
|
loadMatches();
|
||||||
@@ -257,6 +263,47 @@
|
|||||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderEventCards(events, listId, sectionId) {
|
||||||
|
if (!events.length) return;
|
||||||
|
const list = document.getElementById(listId);
|
||||||
|
list.innerHTML = events.map(e => {
|
||||||
|
const thumb = e.imageData
|
||||||
|
? `<img src="data:image/jpeg;base64,${e.imageData}" alt="">`
|
||||||
|
: '🗓';
|
||||||
|
const date = new Date(e.startAt);
|
||||||
|
const dateStr = date.toLocaleDateString('de-DE', { weekday:'short', day:'numeric', month:'short' })
|
||||||
|
+ ', ' + date.toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' }) + ' Uhr';
|
||||||
|
return `
|
||||||
|
<a class="loc-event-card" href="/community/event-detail.html?id=${e.eventId}">
|
||||||
|
<div class="loc-event-thumb">${thumb}</div>
|
||||||
|
<div class="loc-event-body">
|
||||||
|
<div class="loc-event-location">${esc(e.locationName)}</div>
|
||||||
|
<div class="loc-event-title">${esc(e.title)}</div>
|
||||||
|
<div class="loc-event-date">${dateStr}</div>
|
||||||
|
</div>
|
||||||
|
</a>`;
|
||||||
|
}).join('');
|
||||||
|
document.getElementById(sectionId).style.display = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMyEvents() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/location-events/attending-next');
|
||||||
|
if (!res.ok) return;
|
||||||
|
const events = await res.json();
|
||||||
|
renderEventCards(events, 'myEventsList', 'myEventsSection');
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLocEvents() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/location-events/followed-next');
|
||||||
|
if (!res.ok) return;
|
||||||
|
const events = await res.json();
|
||||||
|
renderEventCards(events, 'locEventsList', 'locEventsSection');
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadVisitors() {
|
async function loadVisitors() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/social/profile-visits/my-visitors');
|
const res = await fetch('/social/profile-visits/my-visitors');
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import java.util.List;
|
|||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.data.domain.Slice;
|
import org.springframework.data.domain.Slice;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@@ -47,9 +49,6 @@ import de.oaa.xxx.user.UserEntity;
|
|||||||
import de.oaa.xxx.user.UserRepository;
|
import de.oaa.xxx.user.UserRepository;
|
||||||
import de.oaa.xxx.user.UserService;
|
import de.oaa.xxx.user.UserService;
|
||||||
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/feed")
|
@RequestMapping("/feed")
|
||||||
public class FeedController {
|
public class FeedController {
|
||||||
@@ -365,7 +364,8 @@ public class FeedController {
|
|||||||
p.getCreatedAt(),
|
p.getCreatedAt(),
|
||||||
likeCount, likedByMe, kommentarCount,
|
likeCount, likedByMe, kommentarCount,
|
||||||
optionen, myVoteOptionIds,
|
optionen, myVoteOptionIds,
|
||||||
p.isPublic()
|
p.isPublic(),
|
||||||
|
p.getTargetUrl()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,7 +402,8 @@ public class FeedController {
|
|||||||
b.getCreatedAt(),
|
b.getCreatedAt(),
|
||||||
likeCount, likedByMe, kommentarCount,
|
likeCount, likedByMe, kommentarCount,
|
||||||
optionen, myVoteOptionIds,
|
optionen, myVoteOptionIds,
|
||||||
false
|
false,
|
||||||
|
null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,5 +24,6 @@ public record FeedItemDto(
|
|||||||
long kommentarCount,
|
long kommentarCount,
|
||||||
List<UmfrageOptionDto> optionen,
|
List<UmfrageOptionDto> optionen,
|
||||||
List<UUID> myVoteOptionIds,
|
List<UUID> myVoteOptionIds,
|
||||||
boolean isPublic
|
boolean isPublic,
|
||||||
|
String targetUrl
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
@@ -42,4 +42,7 @@ public class FeedPostEntity {
|
|||||||
|
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
private String targetUrl;
|
||||||
}
|
}
|
||||||
|
|||||||
176
src/main/java/de/oaa/xxx/location/LocationAdminController.java
Normal file
176
src/main/java/de/oaa/xxx/location/LocationAdminController.java
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
package de.oaa.xxx.location;
|
||||||
|
|
||||||
|
import de.oaa.xxx.location.entity.LocationAdminEntity;
|
||||||
|
import de.oaa.xxx.location.repository.LocationAdminRepository;
|
||||||
|
import de.oaa.xxx.location.repository.LocationRepository;
|
||||||
|
import de.oaa.xxx.user.UserRepository;
|
||||||
|
import de.oaa.xxx.user.UserService;
|
||||||
|
import jakarta.transaction.Transactional;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.security.Principal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/locations/{locationId}/admins")
|
||||||
|
public class LocationAdminController {
|
||||||
|
|
||||||
|
record AdminDto(UUID userId, String name, String profilePicture, boolean isOwner) {}
|
||||||
|
record AddAdminRequest(UUID userId) {}
|
||||||
|
record TransferOwnerRequest(UUID userId) {}
|
||||||
|
|
||||||
|
private final LocationRepository locationRepo;
|
||||||
|
private final LocationAdminRepository adminRepo;
|
||||||
|
private final UserRepository userRepo;
|
||||||
|
private final UserService userService;
|
||||||
|
|
||||||
|
public LocationAdminController(LocationRepository locationRepo,
|
||||||
|
LocationAdminRepository adminRepo,
|
||||||
|
UserRepository userRepo,
|
||||||
|
UserService userService) {
|
||||||
|
this.locationRepo = locationRepo;
|
||||||
|
this.adminRepo = adminRepo;
|
||||||
|
this.userRepo = userRepo;
|
||||||
|
this.userService = userService;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hilfsmethoden ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private boolean isAdmin(UUID locationId, UUID userId, de.oaa.xxx.location.entity.LocationEntity loc) {
|
||||||
|
return loc.getOwnerId().equals(userId)
|
||||||
|
|| adminRepo.existsByLocationIdAndUserId(locationId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private AdminDto toDto(UUID userId, de.oaa.xxx.location.entity.LocationEntity loc) {
|
||||||
|
return userRepo.findById(userId).map(u -> new AdminDto(
|
||||||
|
u.getUserId(), u.getName(), u.getProfilePicture(),
|
||||||
|
u.getUserId().equals(loc.getOwnerId())))
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Admins auflisten ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<List<AdminDto>> list(
|
||||||
|
@PathVariable UUID locationId,
|
||||||
|
Principal principal) {
|
||||||
|
|
||||||
|
userService.requireUser(principal);
|
||||||
|
var locOpt = locationRepo.findById(locationId);
|
||||||
|
if (locOpt.isEmpty()) return ResponseEntity.notFound().build();
|
||||||
|
var loc = locOpt.get();
|
||||||
|
|
||||||
|
// Inhaber ist immer erster Eintrag
|
||||||
|
List<AdminDto> admins = new java.util.ArrayList<>();
|
||||||
|
userRepo.findById(loc.getOwnerId()).ifPresent(owner ->
|
||||||
|
admins.add(new AdminDto(owner.getUserId(), owner.getName(), owner.getProfilePicture(), true)));
|
||||||
|
|
||||||
|
adminRepo.findByLocationId(locationId).stream()
|
||||||
|
.filter(a -> !a.getUserId().equals(loc.getOwnerId())) // Inhaber nicht doppelt
|
||||||
|
.map(a -> toDto(a.getUserId(), loc))
|
||||||
|
.filter(java.util.Objects::nonNull)
|
||||||
|
.forEach(admins::add);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(admins);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Admin hinzufügen ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<AdminDto> add(
|
||||||
|
@PathVariable UUID locationId,
|
||||||
|
@RequestBody AddAdminRequest req,
|
||||||
|
Principal principal) {
|
||||||
|
|
||||||
|
UUID myId = userService.requireUser(principal).getUserId();
|
||||||
|
var locOpt = locationRepo.findById(locationId);
|
||||||
|
if (locOpt.isEmpty()) return ResponseEntity.notFound().build();
|
||||||
|
var loc = locOpt.get();
|
||||||
|
|
||||||
|
if (!isAdmin(locationId, myId, loc)) return ResponseEntity.status(403).build();
|
||||||
|
if (req.userId() == null || !userRepo.existsById(req.userId()))
|
||||||
|
return ResponseEntity.badRequest().build();
|
||||||
|
|
||||||
|
// Inhaber muss nicht eingetragen werden
|
||||||
|
if (req.userId().equals(loc.getOwnerId()))
|
||||||
|
return ResponseEntity.badRequest().build();
|
||||||
|
|
||||||
|
// Bereits Admin?
|
||||||
|
if (adminRepo.existsByLocationIdAndUserId(locationId, req.userId()))
|
||||||
|
return ResponseEntity.status(409).build();
|
||||||
|
|
||||||
|
LocationAdminEntity entity = new LocationAdminEntity();
|
||||||
|
entity.setAdminId(UUID.randomUUID());
|
||||||
|
entity.setLocationId(locationId);
|
||||||
|
entity.setUserId(req.userId());
|
||||||
|
entity.setAddedAt(LocalDateTime.now());
|
||||||
|
adminRepo.save(entity);
|
||||||
|
|
||||||
|
return ResponseEntity.status(201).body(toDto(req.userId(), loc));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Admin entfernen ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@DeleteMapping("/{userId}")
|
||||||
|
public ResponseEntity<Void> remove(
|
||||||
|
@PathVariable UUID locationId,
|
||||||
|
@PathVariable UUID userId,
|
||||||
|
Principal principal) {
|
||||||
|
|
||||||
|
UUID myId = userService.requireUser(principal).getUserId();
|
||||||
|
var locOpt = locationRepo.findById(locationId);
|
||||||
|
if (locOpt.isEmpty()) return ResponseEntity.notFound().build();
|
||||||
|
var loc = locOpt.get();
|
||||||
|
|
||||||
|
if (!isAdmin(locationId, myId, loc)) return ResponseEntity.status(403).build();
|
||||||
|
|
||||||
|
// Inhaber darf nicht entfernt werden
|
||||||
|
if (userId.equals(loc.getOwnerId())) return ResponseEntity.status(403).build();
|
||||||
|
|
||||||
|
adminRepo.deleteByLocationIdAndUserId(locationId, userId);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Inhaberwechsel ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@PutMapping("/transfer-owner")
|
||||||
|
public ResponseEntity<Map<String, Object>> transferOwner(
|
||||||
|
@PathVariable UUID locationId,
|
||||||
|
@RequestBody TransferOwnerRequest req,
|
||||||
|
Principal principal) {
|
||||||
|
|
||||||
|
UUID myId = userService.requireUser(principal).getUserId();
|
||||||
|
var locOpt = locationRepo.findById(locationId);
|
||||||
|
if (locOpt.isEmpty()) return ResponseEntity.notFound().build();
|
||||||
|
var loc = locOpt.get();
|
||||||
|
|
||||||
|
// Nur der aktuelle Inhaber darf übertragen
|
||||||
|
if (!loc.getOwnerId().equals(myId)) return ResponseEntity.status(403).build();
|
||||||
|
if (req.userId() == null || !userRepo.existsById(req.userId()))
|
||||||
|
return ResponseEntity.badRequest().build();
|
||||||
|
if (req.userId().equals(myId)) return ResponseEntity.badRequest().build();
|
||||||
|
|
||||||
|
// Neuer Inhaber als Admin eintragen (falls noch nicht), alter Inhaber wird normaler Admin
|
||||||
|
if (!adminRepo.existsByLocationIdAndUserId(locationId, myId)) {
|
||||||
|
LocationAdminEntity a = new LocationAdminEntity();
|
||||||
|
a.setAdminId(UUID.randomUUID());
|
||||||
|
a.setLocationId(locationId);
|
||||||
|
a.setUserId(myId);
|
||||||
|
a.setAddedAt(LocalDateTime.now());
|
||||||
|
adminRepo.save(a);
|
||||||
|
}
|
||||||
|
// Neuen Inhaber aus Admin-Liste entfernen (er ist jetzt Owner)
|
||||||
|
adminRepo.deleteByLocationIdAndUserId(locationId, req.userId());
|
||||||
|
|
||||||
|
loc.setOwnerId(req.userId());
|
||||||
|
locationRepo.save(loc);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(Map.of("newOwnerId", req.userId()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package de.oaa.xxx.location;
|
||||||
|
|
||||||
|
import de.oaa.xxx.location.entity.LocationEntity;
|
||||||
|
import de.oaa.xxx.location.repository.LocationInboxLockRepository;
|
||||||
|
import de.oaa.xxx.location.repository.LocationRepository;
|
||||||
|
import de.oaa.xxx.social.entity.MessageEntity;
|
||||||
|
import de.oaa.xxx.social.repository.MessageRepository;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class LocationChatCleanupService {
|
||||||
|
|
||||||
|
private static final Logger LOGGER = LoggerFactory.getLogger(LocationChatCleanupService.class);
|
||||||
|
|
||||||
|
private final LocationRepository locationRepo;
|
||||||
|
private final MessageRepository messageRepo;
|
||||||
|
private final LocationInboxLockRepository lockRepo;
|
||||||
|
|
||||||
|
public LocationChatCleanupService(LocationRepository locationRepo,
|
||||||
|
MessageRepository messageRepo,
|
||||||
|
LocationInboxLockRepository lockRepo) {
|
||||||
|
this.locationRepo = locationRepo;
|
||||||
|
this.messageRepo = messageRepo;
|
||||||
|
this.lockRepo = lockRepo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Täglich um 03:00 Uhr: Location-Chats löschen, die seit mehr als einem Monat inaktiv sind. */
|
||||||
|
@Scheduled(cron = "0 0 3 * * *")
|
||||||
|
@Transactional
|
||||||
|
public void cleanupInactiveChats() {
|
||||||
|
LocalDateTime cutoff = LocalDateTime.now().minusMonths(1);
|
||||||
|
List<LocationEntity> locations = locationRepo.findByVirtualUserIdIsNotNull();
|
||||||
|
int deleted = 0;
|
||||||
|
|
||||||
|
for (LocationEntity loc : locations) {
|
||||||
|
UUID virtualId = loc.getVirtualUserId();
|
||||||
|
|
||||||
|
// Alle Nachrichten dieser Location (in beide Richtungen)
|
||||||
|
List<MessageEntity> allMessages = messageRepo.findAllByUser(virtualId);
|
||||||
|
if (allMessages.isEmpty()) continue;
|
||||||
|
|
||||||
|
// Neueste Nachricht pro Gesprächspartner (Besucher)
|
||||||
|
Map<UUID, LocalDateTime> latestByVisitor = new HashMap<>();
|
||||||
|
for (MessageEntity m : allMessages) {
|
||||||
|
UUID visitor = m.getSenderId().equals(virtualId) ? m.getReceiverId() : m.getSenderId();
|
||||||
|
latestByVisitor.merge(visitor, m.getSentAt(),
|
||||||
|
(a, b) -> a.isAfter(b) ? a : b);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (Map.Entry<UUID, LocalDateTime> entry : latestByVisitor.entrySet()) {
|
||||||
|
if (entry.getValue().isBefore(cutoff)) {
|
||||||
|
UUID visitorId = entry.getKey();
|
||||||
|
messageRepo.deleteConversation(virtualId, visitorId);
|
||||||
|
lockRepo.findByLocationIdAndVisitorId(loc.getLocationId(), visitorId)
|
||||||
|
.ifPresent(lockRepo::delete);
|
||||||
|
deleted++;
|
||||||
|
LOGGER.info("Inaktiver Location-Chat gelöscht: location={} visitor={}", loc.getLocationId(), visitorId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleted > 0) {
|
||||||
|
LOGGER.info("Location-Chat-Cleanup abgeschlossen: {} Konversation(en) gelöscht.", deleted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
package de.oaa.xxx.location;
|
package de.oaa.xxx.location;
|
||||||
|
|
||||||
import de.oaa.xxx.location.entity.*;
|
import de.oaa.xxx.location.entity.*;
|
||||||
|
import de.oaa.xxx.location.entity.LocationInboxLockEntity;
|
||||||
import de.oaa.xxx.location.repository.*;
|
import de.oaa.xxx.location.repository.*;
|
||||||
|
import de.oaa.xxx.location.repository.LocationInboxLockRepository;
|
||||||
|
import de.oaa.xxx.social.SseService;
|
||||||
|
import de.oaa.xxx.social.entity.MessageEntity;
|
||||||
|
import de.oaa.xxx.social.repository.MessageRepository;
|
||||||
|
import de.oaa.xxx.user.UserRepository;
|
||||||
import de.oaa.xxx.user.UserService;
|
import de.oaa.xxx.user.UserService;
|
||||||
import jakarta.transaction.Transactional;
|
import jakarta.transaction.Transactional;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
@@ -12,6 +18,7 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
import java.security.Principal;
|
import java.security.Principal;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@@ -22,13 +29,20 @@ public class LocationController {
|
|||||||
private static final int MAX_GALLERY_IMAGES = 20;
|
private static final int MAX_GALLERY_IMAGES = 20;
|
||||||
private static final int MAX_BATCH_SIZE = 50;
|
private static final int MAX_BATCH_SIZE = 50;
|
||||||
|
|
||||||
|
private static final int LOCK_TIMEOUT_MINUTES = 60;
|
||||||
|
|
||||||
private final LocationRepository locationRepo;
|
private final LocationRepository locationRepo;
|
||||||
private final LocationImageRepository imageRepo;
|
private final LocationImageRepository imageRepo;
|
||||||
private final LocationOpeningHoursRepository hoursRepo;
|
private final LocationOpeningHoursRepository hoursRepo;
|
||||||
private final LocationEventRepository eventRepo;
|
private final LocationEventRepository eventRepo;
|
||||||
private final LocationEventAttendeeRepository attendeeRepo;
|
private final LocationEventAttendeeRepository attendeeRepo;
|
||||||
private final LocationFollowRepository followRepo;
|
private final LocationFollowRepository followRepo;
|
||||||
|
private final LocationAdminRepository adminRepo;
|
||||||
|
private final UserRepository userRepo;
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
|
private final MessageRepository messageRepo;
|
||||||
|
private final SseService sseService;
|
||||||
|
private final LocationInboxLockRepository lockRepo;
|
||||||
|
|
||||||
public LocationController(LocationRepository locationRepo,
|
public LocationController(LocationRepository locationRepo,
|
||||||
LocationImageRepository imageRepo,
|
LocationImageRepository imageRepo,
|
||||||
@@ -36,26 +50,38 @@ public class LocationController {
|
|||||||
LocationEventRepository eventRepo,
|
LocationEventRepository eventRepo,
|
||||||
LocationEventAttendeeRepository attendeeRepo,
|
LocationEventAttendeeRepository attendeeRepo,
|
||||||
LocationFollowRepository followRepo,
|
LocationFollowRepository followRepo,
|
||||||
UserService userService) {
|
LocationAdminRepository adminRepo,
|
||||||
|
UserRepository userRepo,
|
||||||
|
UserService userService,
|
||||||
|
MessageRepository messageRepo,
|
||||||
|
SseService sseService,
|
||||||
|
LocationInboxLockRepository lockRepo) {
|
||||||
this.locationRepo = locationRepo;
|
this.locationRepo = locationRepo;
|
||||||
this.imageRepo = imageRepo;
|
this.imageRepo = imageRepo;
|
||||||
this.hoursRepo = hoursRepo;
|
this.hoursRepo = hoursRepo;
|
||||||
this.eventRepo = eventRepo;
|
this.eventRepo = eventRepo;
|
||||||
this.attendeeRepo = attendeeRepo;
|
this.attendeeRepo = attendeeRepo;
|
||||||
this.followRepo = followRepo;
|
this.followRepo = followRepo;
|
||||||
|
this.adminRepo = adminRepo;
|
||||||
|
this.userRepo = userRepo;
|
||||||
this.userService = userService;
|
this.userService = userService;
|
||||||
|
this.messageRepo = messageRepo;
|
||||||
|
this.sseService = sseService;
|
||||||
|
this.lockRepo = lockRepo;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── DTOs ─────────────────────────────────────────────────────────────────
|
// ── DTOs ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
record IdsResult(List<UUID> ids, int total) {}
|
record IdsResult(List<UUID> ids, int total) {}
|
||||||
|
|
||||||
record LocationPreviewDto(UUID locationId, String name, String profilePictureLq, double distanzKm) {}
|
record LocationPreviewDto(UUID locationId, String name, String profilePictureHq, double distanzKm) {}
|
||||||
|
|
||||||
record OpeningHourDto(int dayOfWeek, String openTime, String closeTime, boolean closed) {}
|
record OpeningHourDto(int dayOfWeek, String openTime, String closeTime, boolean closed) {}
|
||||||
|
|
||||||
record GalleryImageDto(UUID imageId, String imageData) {}
|
record GalleryImageDto(UUID imageId, String imageData) {}
|
||||||
|
|
||||||
|
record AdminDto(UUID userId, String name, String profilePicture, boolean isOwner) {}
|
||||||
|
|
||||||
record LocationDetailDto(
|
record LocationDetailDto(
|
||||||
UUID locationId,
|
UUID locationId,
|
||||||
UUID ownerId,
|
UUID ownerId,
|
||||||
@@ -71,7 +97,10 @@ public class LocationController {
|
|||||||
LocalDateTime createdAt,
|
LocalDateTime createdAt,
|
||||||
List<GalleryImageDto> gallery,
|
List<GalleryImageDto> gallery,
|
||||||
List<OpeningHourDto> openingHours,
|
List<OpeningHourDto> openingHours,
|
||||||
boolean following
|
boolean following,
|
||||||
|
List<AdminDto> admins,
|
||||||
|
boolean isAdmin,
|
||||||
|
UUID virtualUserId
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
record CreateRequest(
|
record CreateRequest(
|
||||||
@@ -99,6 +128,21 @@ public class LocationController {
|
|||||||
|
|
||||||
record GalleryUploadRequest(String imageData) {}
|
record GalleryUploadRequest(String imageData) {}
|
||||||
|
|
||||||
|
record LocationVirtualInfoDto(UUID locationId, UUID virtualUserId, String name,
|
||||||
|
String profilePictureLq, String profilePictureHq) {}
|
||||||
|
|
||||||
|
/** Löst eine virtuelle Benutzer-ID zur Location-Info auf (für Nachrichtenfenster) */
|
||||||
|
@GetMapping("/virtual/{virtualUserId}")
|
||||||
|
public ResponseEntity<LocationVirtualInfoDto> getByVirtualUserId(
|
||||||
|
@PathVariable UUID virtualUserId, Principal principal) {
|
||||||
|
userService.requireUser(principal);
|
||||||
|
return locationRepo.findByVirtualUserId(virtualUserId)
|
||||||
|
.map(l -> ResponseEntity.ok(new LocationVirtualInfoDto(
|
||||||
|
l.getLocationId(), l.getVirtualUserId(), l.getName(),
|
||||||
|
l.getProfilePictureLq(), l.getProfilePictureHq())))
|
||||||
|
.orElse(ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
|
||||||
// ── Suche / IDs ──────────────────────────────────────────────────────────
|
// ── Suche / IDs ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -147,7 +191,7 @@ public class LocationController {
|
|||||||
.map(l -> new LocationPreviewDto(
|
.map(l -> new LocationPreviewDto(
|
||||||
l.getLocationId(),
|
l.getLocationId(),
|
||||||
l.getName(),
|
l.getName(),
|
||||||
l.getProfilePictureLq(),
|
l.getProfilePictureHq(),
|
||||||
l.getLat() != null && l.getLon() != null
|
l.getLat() != null && l.getLon() != null
|
||||||
? Math.round(haversineKm(refLat, refLon, l.getLat(), l.getLon()) * 10.0) / 10.0
|
? Math.round(haversineKm(refLat, refLon, l.getLat(), l.getLon()) * 10.0) / 10.0
|
||||||
: -1))
|
: -1))
|
||||||
@@ -207,6 +251,7 @@ public class LocationController {
|
|||||||
loc.setCity(req.city());
|
loc.setCity(req.city());
|
||||||
loc.setOwnershipConfirmed(req.ownershipConfirmed());
|
loc.setOwnershipConfirmed(req.ownershipConfirmed());
|
||||||
loc.setCreatedAt(LocalDateTime.now());
|
loc.setCreatedAt(LocalDateTime.now());
|
||||||
|
loc.setVirtualUserId(UUID.randomUUID());
|
||||||
locationRepo.save(loc);
|
locationRepo.save(loc);
|
||||||
|
|
||||||
LOGGER.info("User {} hat Location {} angelegt", myId, loc.getLocationId());
|
LOGGER.info("User {} hat Location {} angelegt", myId, loc.getLocationId());
|
||||||
@@ -257,6 +302,7 @@ public class LocationController {
|
|||||||
imageRepo.deleteByLocationId(locationId);
|
imageRepo.deleteByLocationId(locationId);
|
||||||
hoursRepo.deleteByLocationId(locationId);
|
hoursRepo.deleteByLocationId(locationId);
|
||||||
followRepo.deleteByLocationId(locationId);
|
followRepo.deleteByLocationId(locationId);
|
||||||
|
adminRepo.deleteByLocationId(locationId);
|
||||||
locationRepo.deleteById(locationId);
|
locationRepo.deleteById(locationId);
|
||||||
|
|
||||||
LOGGER.info("User {} hat Location {} gelöscht", myId, locationId);
|
LOGGER.info("User {} hat Location {} gelöscht", myId, locationId);
|
||||||
@@ -398,6 +444,12 @@ public class LocationController {
|
|||||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private LocationDetailDto toDetail(LocationEntity l, UUID myId) {
|
private LocationDetailDto toDetail(LocationEntity l, UUID myId) {
|
||||||
|
// Lazy-Init virtualUserId für Bestandsdaten ohne virtuelle ID
|
||||||
|
if (l.getVirtualUserId() == null) {
|
||||||
|
l.setVirtualUserId(UUID.randomUUID());
|
||||||
|
locationRepo.save(l);
|
||||||
|
}
|
||||||
|
|
||||||
List<GalleryImageDto> gallery = imageRepo.findByLocationIdOrderByUploadedAtAsc(l.getLocationId()).stream()
|
List<GalleryImageDto> gallery = imageRepo.findByLocationIdOrderByUploadedAtAsc(l.getLocationId()).stream()
|
||||||
.map(i -> new GalleryImageDto(i.getImageId(), i.getImageData()))
|
.map(i -> new GalleryImageDto(i.getImageId(), i.getImageData()))
|
||||||
.toList();
|
.toList();
|
||||||
@@ -405,12 +457,241 @@ public class LocationController {
|
|||||||
.map(h -> new OpeningHourDto(h.getDayOfWeek(), h.getOpenTime(), h.getCloseTime(), h.isClosed()))
|
.map(h -> new OpeningHourDto(h.getDayOfWeek(), h.getOpenTime(), h.getCloseTime(), h.isClosed()))
|
||||||
.toList();
|
.toList();
|
||||||
boolean following = followRepo.findByUserIdAndLocationId(myId, l.getLocationId()).isPresent();
|
boolean following = followRepo.findByUserIdAndLocationId(myId, l.getLocationId()).isPresent();
|
||||||
|
boolean isAdmin = l.getOwnerId().equals(myId)
|
||||||
|
|| adminRepo.existsByLocationIdAndUserId(l.getLocationId(), myId);
|
||||||
|
|
||||||
|
// Inhaber zuerst, dann weitere Admins
|
||||||
|
List<AdminDto> admins = new ArrayList<>();
|
||||||
|
userRepo.findById(l.getOwnerId()).ifPresent(owner ->
|
||||||
|
admins.add(new AdminDto(owner.getUserId(), owner.getName(), owner.getProfilePicture(), true)));
|
||||||
|
adminRepo.findByLocationId(l.getLocationId()).stream()
|
||||||
|
.filter(a -> !a.getUserId().equals(l.getOwnerId()))
|
||||||
|
.forEach(a -> userRepo.findById(a.getUserId()).ifPresent(u ->
|
||||||
|
admins.add(new AdminDto(u.getUserId(), u.getName(), u.getProfilePicture(), false))));
|
||||||
|
|
||||||
return new LocationDetailDto(
|
return new LocationDetailDto(
|
||||||
l.getLocationId(), l.getOwnerId(), l.getName(), l.getDescription(),
|
l.getLocationId(), l.getOwnerId(), l.getName(), l.getDescription(),
|
||||||
l.getProfilePictureHq(), l.getProfilePictureLq(),
|
l.getProfilePictureHq(), l.getProfilePictureLq(),
|
||||||
l.getLat(), l.getLon(), l.getStreet(), l.getCity(),
|
l.getLat(), l.getLon(), l.getStreet(), l.getCity(),
|
||||||
l.isOwnershipConfirmed(), l.getCreatedAt(),
|
l.isOwnershipConfirmed(), l.getCreatedAt(),
|
||||||
gallery, hours, following);
|
gallery, hours, following, admins, isAdmin, l.getVirtualUserId());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Location-Posteingang (Admin) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
record InboxSummaryDto(UUID senderId, String senderName, String senderPicture,
|
||||||
|
String lastMessage, LocalDateTime sentAt, long unreadCount) {}
|
||||||
|
|
||||||
|
record InboxConversationDto(
|
||||||
|
List<de.oaa.xxx.social.dto.MessageDto> messages,
|
||||||
|
boolean canReply,
|
||||||
|
boolean lockedByMe,
|
||||||
|
String lockedByName // null wenn frei oder von mir gesperrt
|
||||||
|
) {}
|
||||||
|
|
||||||
|
record ReplyRequest(String text) {}
|
||||||
|
|
||||||
|
/** Alle Konversationen, die Besucher mit dieser Location geführt haben */
|
||||||
|
@GetMapping("/{locationId}/inbox")
|
||||||
|
public ResponseEntity<List<InboxSummaryDto>> getInbox(
|
||||||
|
@PathVariable UUID locationId, Principal principal) {
|
||||||
|
|
||||||
|
UUID myId = userService.requireUser(principal).getUserId();
|
||||||
|
var locOpt = locationRepo.findById(locationId);
|
||||||
|
if (locOpt.isEmpty()) return ResponseEntity.notFound().build();
|
||||||
|
LocationEntity loc = locOpt.get();
|
||||||
|
if (!loc.getOwnerId().equals(myId) && !adminRepo.existsByLocationIdAndUserId(locationId, myId)) {
|
||||||
|
return ResponseEntity.status(403).build();
|
||||||
|
}
|
||||||
|
UUID virtualId = loc.getVirtualUserId();
|
||||||
|
if (virtualId == null) return ResponseEntity.ok(List.of());
|
||||||
|
|
||||||
|
List<MessageEntity> allMessages = messageRepo.findAllByUser(virtualId);
|
||||||
|
|
||||||
|
// Neueste Nachricht pro Gesprächspartner (Besucher)
|
||||||
|
Map<UUID, MessageEntity> latestByPartner = new LinkedHashMap<>();
|
||||||
|
for (MessageEntity m : allMessages) {
|
||||||
|
UUID partnerId = m.getSenderId().equals(virtualId) ? m.getReceiverId() : m.getSenderId();
|
||||||
|
latestByPartner.putIfAbsent(partnerId, m);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<InboxSummaryDto> summaries = new ArrayList<>();
|
||||||
|
for (Map.Entry<UUID, MessageEntity> entry : latestByPartner.entrySet()) {
|
||||||
|
UUID partnerId = entry.getKey();
|
||||||
|
MessageEntity lastMsg = entry.getValue();
|
||||||
|
var userOpt = userRepo.findById(partnerId);
|
||||||
|
if (userOpt.isEmpty()) continue;
|
||||||
|
var user = userOpt.get();
|
||||||
|
long unread = allMessages.stream()
|
||||||
|
.filter(m -> m.getSenderId().equals(partnerId)
|
||||||
|
&& m.getReceiverId().equals(virtualId)
|
||||||
|
&& m.getReadAt() == null)
|
||||||
|
.count();
|
||||||
|
String preview = lastMsg.getText().startsWith("data:image/")
|
||||||
|
? "📷 Bild"
|
||||||
|
: lastMsg.getText().substring(0, Math.min(80, lastMsg.getText().length()));
|
||||||
|
summaries.add(new InboxSummaryDto(partnerId, user.getName(), user.getProfilePicture(),
|
||||||
|
preview, lastMsg.getSentAt(), unread));
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(summaries);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Konversation zwischen Location und einem Besucher inkl. Lock-Status (Admin-Sicht) */
|
||||||
|
@GetMapping("/{locationId}/inbox/{userId}")
|
||||||
|
public ResponseEntity<InboxConversationDto> getInboxConversation(
|
||||||
|
@PathVariable UUID locationId,
|
||||||
|
@PathVariable UUID userId,
|
||||||
|
Principal principal) {
|
||||||
|
|
||||||
|
UUID myId = userService.requireUser(principal).getUserId();
|
||||||
|
var locOpt = locationRepo.findById(locationId);
|
||||||
|
if (locOpt.isEmpty()) return ResponseEntity.notFound().build();
|
||||||
|
LocationEntity loc = locOpt.get();
|
||||||
|
if (!loc.getOwnerId().equals(myId) && !adminRepo.existsByLocationIdAndUserId(locationId, myId)) {
|
||||||
|
return ResponseEntity.status(403).build();
|
||||||
|
}
|
||||||
|
UUID virtualId = loc.getVirtualUserId();
|
||||||
|
if (virtualId == null) return ResponseEntity.ok(new InboxConversationDto(List.of(), true, false, null));
|
||||||
|
|
||||||
|
List<MessageEntity> messages = new ArrayList<>(
|
||||||
|
messageRepo.findConversation(virtualId, userId, org.springframework.data.domain.PageRequest.of(0, 100)));
|
||||||
|
Collections.reverse(messages);
|
||||||
|
|
||||||
|
messageRepo.markAsRead(virtualId, userId, LocalDateTime.now());
|
||||||
|
|
||||||
|
List<de.oaa.xxx.social.dto.MessageDto> dtos = messages.stream()
|
||||||
|
.map(m -> {
|
||||||
|
String senderName = userRepo.findById(m.getSenderId())
|
||||||
|
.map(u -> u.getName())
|
||||||
|
.orElse(loc.getName());
|
||||||
|
return new de.oaa.xxx.social.dto.MessageDto(
|
||||||
|
m.getMessageId(), m.getSenderId(), senderName,
|
||||||
|
m.getReceiverId(), m.getText(), m.getSentAt(), m.getReadAt() != null);
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// Lock-Status ermitteln
|
||||||
|
var lockInfo = resolveLockStatus(locationId, userId, myId);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(new InboxConversationDto(dtos, lockInfo[0].equals("true"), lockInfo[1].equals("true"), lockInfo[2]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sperre für eine Konversation anfordern (Admin beginnt zu antworten) */
|
||||||
|
@PostMapping("/{locationId}/inbox/{userId}/lock")
|
||||||
|
public ResponseEntity<Map<String, Object>> acquireLock(
|
||||||
|
@PathVariable UUID locationId,
|
||||||
|
@PathVariable UUID userId,
|
||||||
|
Principal principal) {
|
||||||
|
|
||||||
|
UUID myId = userService.requireUser(principal).getUserId();
|
||||||
|
var locOpt = locationRepo.findById(locationId);
|
||||||
|
if (locOpt.isEmpty()) return ResponseEntity.notFound().build();
|
||||||
|
LocationEntity loc = locOpt.get();
|
||||||
|
if (!loc.getOwnerId().equals(myId) && !adminRepo.existsByLocationIdAndUserId(locationId, myId)) {
|
||||||
|
return ResponseEntity.status(403).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingOpt = lockRepo.findByLocationIdAndVisitorId(locationId, userId);
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
|
||||||
|
if (existingOpt.isPresent()) {
|
||||||
|
LocationInboxLockEntity lock = existingOpt.get();
|
||||||
|
boolean expired = lock.getLockedAt().isBefore(now.minusMinutes(LOCK_TIMEOUT_MINUTES));
|
||||||
|
if (!expired && !lock.getLockedByUserId().equals(myId)) {
|
||||||
|
// Aktiv gesperrt durch einen anderen Admin
|
||||||
|
String lockerName = userRepo.findById(lock.getLockedByUserId())
|
||||||
|
.map(u -> u.getName()).orElse("einem anderen Admin");
|
||||||
|
return ResponseEntity.status(409).body(Map.of("lockedByName", lockerName));
|
||||||
|
}
|
||||||
|
// Abgelaufen oder bereits meine Sperre → erneuern
|
||||||
|
lock.setLockedByUserId(myId);
|
||||||
|
lock.setLockedAt(now);
|
||||||
|
lockRepo.save(lock);
|
||||||
|
} else {
|
||||||
|
LocationInboxLockEntity lock = new LocationInboxLockEntity();
|
||||||
|
lock.setLockId(UUID.randomUUID());
|
||||||
|
lock.setLocationId(locationId);
|
||||||
|
lock.setVisitorId(userId);
|
||||||
|
lock.setLockedByUserId(myId);
|
||||||
|
lock.setLockedAt(now);
|
||||||
|
lockRepo.save(lock);
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(Map.of("acquired", true));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Antwort als Location an einen Besucher senden (mit Lock-Prüfung) */
|
||||||
|
@PostMapping("/{locationId}/inbox/{userId}/reply")
|
||||||
|
public ResponseEntity<Map<String, Object>> replyAsLocation(
|
||||||
|
@PathVariable UUID locationId,
|
||||||
|
@PathVariable UUID userId,
|
||||||
|
@RequestBody ReplyRequest req,
|
||||||
|
Principal principal) {
|
||||||
|
|
||||||
|
UUID myId = userService.requireUser(principal).getUserId();
|
||||||
|
var locOpt = locationRepo.findById(locationId);
|
||||||
|
if (locOpt.isEmpty()) return ResponseEntity.notFound().build();
|
||||||
|
LocationEntity loc = locOpt.get();
|
||||||
|
if (!loc.getOwnerId().equals(myId) && !adminRepo.existsByLocationIdAndUserId(locationId, myId)) {
|
||||||
|
return ResponseEntity.status(403).build();
|
||||||
|
}
|
||||||
|
if (req.text() == null || req.text().isBlank()) return ResponseEntity.badRequest().build();
|
||||||
|
UUID virtualId = loc.getVirtualUserId();
|
||||||
|
if (virtualId == null) return ResponseEntity.badRequest().build();
|
||||||
|
|
||||||
|
// Lock prüfen
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
var existingLockOpt = lockRepo.findByLocationIdAndVisitorId(locationId, userId);
|
||||||
|
if (existingLockOpt.isPresent()) {
|
||||||
|
LocationInboxLockEntity lock = existingLockOpt.get();
|
||||||
|
boolean expired = lock.getLockedAt().isBefore(now.minusMinutes(LOCK_TIMEOUT_MINUTES));
|
||||||
|
if (!expired && !lock.getLockedByUserId().equals(myId)) {
|
||||||
|
String lockerName = userRepo.findById(lock.getLockedByUserId())
|
||||||
|
.map(u -> u.getName()).orElse("einem anderen Admin");
|
||||||
|
return ResponseEntity.status(409).body(Map.of("lockedByName", lockerName));
|
||||||
|
}
|
||||||
|
// Abgelaufen oder meine Sperre → erneuern
|
||||||
|
lock.setLockedByUserId(myId);
|
||||||
|
lock.setLockedAt(now);
|
||||||
|
lockRepo.save(lock);
|
||||||
|
} else {
|
||||||
|
// Noch keine Sperre → automatisch erwerben
|
||||||
|
LocationInboxLockEntity lock = new LocationInboxLockEntity();
|
||||||
|
lock.setLockId(UUID.randomUUID());
|
||||||
|
lock.setLocationId(locationId);
|
||||||
|
lock.setVisitorId(userId);
|
||||||
|
lock.setLockedByUserId(myId);
|
||||||
|
lock.setLockedAt(now);
|
||||||
|
lockRepo.save(lock);
|
||||||
|
}
|
||||||
|
|
||||||
|
MessageEntity msg = new MessageEntity();
|
||||||
|
msg.setMessageId(UUID.randomUUID());
|
||||||
|
msg.setSenderId(virtualId);
|
||||||
|
msg.setReceiverId(userId);
|
||||||
|
msg.setText(req.text().trim());
|
||||||
|
msg.setSentAt(now);
|
||||||
|
messageRepo.save(msg);
|
||||||
|
LOGGER.debug("Location {} hat Antwort an User {} gesendet", locationId, userId);
|
||||||
|
|
||||||
|
long unread = messageRepo.countUnread(userId);
|
||||||
|
sseService.push(userId, "DM", Map.of("unreadCount", unread, "senderId", virtualId.toString()));
|
||||||
|
return ResponseEntity.status(201).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ermittelt den Lock-Status für eine Konversation.
|
||||||
|
* Gibt ein String-Array zurück: [canReply, lockedByMe, lockedByName|null]
|
||||||
|
*/
|
||||||
|
private String[] resolveLockStatus(UUID locationId, UUID visitorId, UUID myId) {
|
||||||
|
var lockOpt = lockRepo.findByLocationIdAndVisitorId(locationId, visitorId);
|
||||||
|
if (lockOpt.isEmpty()) return new String[]{"true", "false", null};
|
||||||
|
LocationInboxLockEntity lock = lockOpt.get();
|
||||||
|
boolean expired = lock.getLockedAt().isBefore(LocalDateTime.now().minusMinutes(LOCK_TIMEOUT_MINUTES));
|
||||||
|
if (expired) return new String[]{"true", "false", null};
|
||||||
|
if (lock.getLockedByUserId().equals(myId)) return new String[]{"true", "true", null};
|
||||||
|
String lockerName = userRepo.findById(lock.getLockedByUserId())
|
||||||
|
.map(u -> u.getName()).orElse("einem anderen Admin");
|
||||||
|
return new String[]{"false", "false", lockerName};
|
||||||
}
|
}
|
||||||
|
|
||||||
static double haversineKm(double lat1, double lon1, double lat2, double lon2) {
|
static double haversineKm(double lat1, double lon1, double lat2, double lon2) {
|
||||||
|
|||||||
@@ -27,10 +27,16 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
import de.oaa.xxx.location.entity.LocationEventAttendeeEntity;
|
import de.oaa.xxx.location.entity.LocationEventAttendeeEntity;
|
||||||
import de.oaa.xxx.location.entity.LocationEventEntity;
|
import de.oaa.xxx.location.entity.LocationEventEntity;
|
||||||
import de.oaa.xxx.location.entity.LocationFollowEntity;
|
import de.oaa.xxx.location.entity.LocationFollowEntity;
|
||||||
|
import de.oaa.xxx.location.repository.LocationAdminRepository;
|
||||||
import de.oaa.xxx.location.repository.LocationEventAttendeeRepository;
|
import de.oaa.xxx.location.repository.LocationEventAttendeeRepository;
|
||||||
import de.oaa.xxx.location.repository.LocationEventRepository;
|
import de.oaa.xxx.location.repository.LocationEventRepository;
|
||||||
import de.oaa.xxx.location.repository.LocationFollowRepository;
|
import de.oaa.xxx.location.repository.LocationFollowRepository;
|
||||||
import de.oaa.xxx.location.repository.LocationRepository;
|
import de.oaa.xxx.location.repository.LocationRepository;
|
||||||
|
import de.oaa.xxx.feed.entity.FeedPostEntity;
|
||||||
|
import de.oaa.xxx.feed.repository.FeedPostRepository;
|
||||||
|
import de.oaa.xxx.gruppe.BeitragTyp;
|
||||||
|
import de.oaa.xxx.social.SystemMessageService;
|
||||||
|
import de.oaa.xxx.social.entity.MessageCause;
|
||||||
import de.oaa.xxx.user.UserEntity;
|
import de.oaa.xxx.user.UserEntity;
|
||||||
import de.oaa.xxx.user.UserRepository;
|
import de.oaa.xxx.user.UserRepository;
|
||||||
import de.oaa.xxx.user.UserService;
|
import de.oaa.xxx.user.UserService;
|
||||||
@@ -46,21 +52,37 @@ public class LocationEventController {
|
|||||||
private final LocationEventRepository eventRepo;
|
private final LocationEventRepository eventRepo;
|
||||||
private final LocationEventAttendeeRepository attendeeRepo;
|
private final LocationEventAttendeeRepository attendeeRepo;
|
||||||
private final LocationFollowRepository followRepo;
|
private final LocationFollowRepository followRepo;
|
||||||
|
private final LocationAdminRepository adminRepo;
|
||||||
private final UserRepository userRepo;
|
private final UserRepository userRepo;
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
|
private final SystemMessageService systemMessageService;
|
||||||
|
private final FeedPostRepository feedPostRepo;
|
||||||
|
|
||||||
public LocationEventController(LocationRepository locationRepo,
|
public LocationEventController(LocationRepository locationRepo,
|
||||||
LocationEventRepository eventRepo,
|
LocationEventRepository eventRepo,
|
||||||
LocationEventAttendeeRepository attendeeRepo,
|
LocationEventAttendeeRepository attendeeRepo,
|
||||||
LocationFollowRepository followRepo,
|
LocationFollowRepository followRepo,
|
||||||
|
LocationAdminRepository adminRepo,
|
||||||
UserRepository userRepo,
|
UserRepository userRepo,
|
||||||
UserService userService) {
|
UserService userService,
|
||||||
|
SystemMessageService systemMessageService,
|
||||||
|
FeedPostRepository feedPostRepo) {
|
||||||
this.locationRepo = locationRepo;
|
this.locationRepo = locationRepo;
|
||||||
this.eventRepo = eventRepo;
|
this.eventRepo = eventRepo;
|
||||||
this.attendeeRepo = attendeeRepo;
|
this.attendeeRepo = attendeeRepo;
|
||||||
this.followRepo = followRepo;
|
this.followRepo = followRepo;
|
||||||
|
this.adminRepo = adminRepo;
|
||||||
this.userRepo = userRepo;
|
this.userRepo = userRepo;
|
||||||
this.userService = userService;
|
this.userService = userService;
|
||||||
|
this.systemMessageService = systemMessageService;
|
||||||
|
this.feedPostRepo = feedPostRepo;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isLocationAdmin(UUID locationId, UUID userId) {
|
||||||
|
return locationRepo.findById(locationId)
|
||||||
|
.map(l -> l.getOwnerId().equals(userId)
|
||||||
|
|| adminRepo.existsByLocationIdAndUserId(locationId, userId))
|
||||||
|
.orElse(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── DTOs ─────────────────────────────────────────────────────────────────
|
// ── DTOs ─────────────────────────────────────────────────────────────────
|
||||||
@@ -91,6 +113,7 @@ public class LocationEventController {
|
|||||||
LocalDateTime startAt,
|
LocalDateTime startAt,
|
||||||
LocalDateTime createdAt,
|
LocalDateTime createdAt,
|
||||||
boolean attendingMe,
|
boolean attendingMe,
|
||||||
|
boolean isAdmin,
|
||||||
List<AttendeeDto> attendees
|
List<AttendeeDto> attendees
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -143,6 +166,25 @@ public class LocationEventController {
|
|||||||
event.setCreatedAt(LocalDateTime.now());
|
event.setCreatedAt(LocalDateTime.now());
|
||||||
eventRepo.save(event);
|
eventRepo.save(event);
|
||||||
|
|
||||||
|
// Feed-Post automatisch anlegen
|
||||||
|
try {
|
||||||
|
String locationName = locOpt.get().getName();
|
||||||
|
String dateStr = event.getStartAt().format(
|
||||||
|
java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy 'um' HH:mm 'Uhr'"));
|
||||||
|
FeedPostEntity feedPost = new FeedPostEntity();
|
||||||
|
feedPost.setPostId(UUID.randomUUID());
|
||||||
|
feedPost.setAuthorId(myId);
|
||||||
|
feedPost.setText("📍 Neue Veranstaltung bei " + locationName + ": \"" + event.getTitle() + "\" - " + dateStr);
|
||||||
|
feedPost.setBilder(event.getImageData() != null ? List.of(event.getImageData()) : List.of());
|
||||||
|
feedPost.setBeitragTyp(BeitragTyp.TEXT);
|
||||||
|
feedPost.setPublic(true);
|
||||||
|
feedPost.setCreatedAt(LocalDateTime.now());
|
||||||
|
feedPost.setTargetUrl("/community/event-detail.html?id=" + event.getEventId());
|
||||||
|
feedPostRepo.save(feedPost);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
LOGGER.warn("Feed-Post für Event {} konnte nicht angelegt werden: {}", event.getEventId(), ex.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
LOGGER.info("Location {} hat Event {} angelegt", locationId, event.getEventId());
|
LOGGER.info("Location {} hat Event {} angelegt", locationId, event.getEventId());
|
||||||
return ResponseEntity.status(201).body(toDetail(event, locOpt.get().getName(), myId));
|
return ResponseEntity.status(201).body(toDetail(event, locOpt.get().getName(), myId));
|
||||||
}
|
}
|
||||||
@@ -157,17 +199,23 @@ public class LocationEventController {
|
|||||||
UUID myId = userService.requireUser(principal).getUserId();
|
UUID myId = userService.requireUser(principal).getUserId();
|
||||||
var locOpt = locationRepo.findById(locationId);
|
var locOpt = locationRepo.findById(locationId);
|
||||||
if (locOpt.isEmpty()) return ResponseEntity.notFound().build();
|
if (locOpt.isEmpty()) return ResponseEntity.notFound().build();
|
||||||
if (!locOpt.get().getOwnerId().equals(myId)) return ResponseEntity.status(403).build();
|
if (!isLocationAdmin(locationId, myId)) return ResponseEntity.status(403).build();
|
||||||
|
|
||||||
var evtOpt = eventRepo.findById(eventId);
|
var evtOpt = eventRepo.findById(eventId);
|
||||||
if (evtOpt.isEmpty()) return ResponseEntity.notFound().build();
|
if (evtOpt.isEmpty()) return ResponseEntity.notFound().build();
|
||||||
if (!evtOpt.get().getLocationId().equals(locationId)) return ResponseEntity.status(400).build();
|
if (!evtOpt.get().getLocationId().equals(locationId)) return ResponseEntity.status(400).build();
|
||||||
|
|
||||||
|
// Veranstaltungen in der Vergangenheit dürfen nicht bearbeitet werden
|
||||||
|
if (evtOpt.get().getStartAt().isBefore(LocalDateTime.now())) return ResponseEntity.status(422).build();
|
||||||
|
|
||||||
LocationEventEntity event = evtOpt.get();
|
LocationEventEntity event = evtOpt.get();
|
||||||
if (req.title() != null && !req.title().isBlank()) event.setTitle(req.title().trim());
|
if (req.title() != null && !req.title().isBlank()) event.setTitle(req.title().trim());
|
||||||
if (req.description() != null) event.setDescription(req.description().trim());
|
if (req.description() != null) event.setDescription(req.description().trim());
|
||||||
if (req.imageData() != null) event.setImageData(req.imageData());
|
if (req.imageData() != null) event.setImageData(req.imageData());
|
||||||
if (req.startAt() != null) event.setStartAt(req.startAt());
|
if (req.startAt() != null) {
|
||||||
|
if (req.startAt().isBefore(LocalDateTime.now())) return ResponseEntity.status(422).build();
|
||||||
|
event.setStartAt(req.startAt());
|
||||||
|
}
|
||||||
eventRepo.save(event);
|
eventRepo.save(event);
|
||||||
|
|
||||||
return ResponseEntity.ok(toDetail(event, locOpt.get().getName(), myId));
|
return ResponseEntity.ok(toDetail(event, locOpt.get().getName(), myId));
|
||||||
@@ -183,14 +231,27 @@ public class LocationEventController {
|
|||||||
UUID myId = userService.requireUser(principal).getUserId();
|
UUID myId = userService.requireUser(principal).getUserId();
|
||||||
var locOpt = locationRepo.findById(locationId);
|
var locOpt = locationRepo.findById(locationId);
|
||||||
if (locOpt.isEmpty()) return ResponseEntity.notFound().build();
|
if (locOpt.isEmpty()) return ResponseEntity.notFound().build();
|
||||||
if (!locOpt.get().getOwnerId().equals(myId)) return ResponseEntity.status(403).build();
|
if (!isLocationAdmin(locationId, myId)) return ResponseEntity.status(403).build();
|
||||||
|
|
||||||
var evtOpt = eventRepo.findById(eventId);
|
var evtOpt = eventRepo.findById(eventId);
|
||||||
if (evtOpt.isEmpty()) return ResponseEntity.notFound().build();
|
if (evtOpt.isEmpty()) return ResponseEntity.notFound().build();
|
||||||
if (!evtOpt.get().getLocationId().equals(locationId)) return ResponseEntity.status(400).build();
|
var evt = evtOpt.get();
|
||||||
|
if (!evt.getLocationId().equals(locationId)) return ResponseEntity.status(400).build();
|
||||||
|
|
||||||
|
// Alle Teilnehmenden sammeln und benachrichtigen
|
||||||
|
List<LocationEventAttendeeEntity> attendees = attendeeRepo.findByEventIdOrderByRegisteredAtAsc(eventId);
|
||||||
|
String locationName = locOpt.get().getName();
|
||||||
|
String notifyText = "Die Veranstaltung \"" + evt.getTitle() + "\" bei " + locationName + " wurde abgesagt.";
|
||||||
|
String targetUrl = "/community/location-detail.html?id=" + locationId;
|
||||||
|
|
||||||
attendeeRepo.deleteByEventId(eventId);
|
attendeeRepo.deleteByEventId(eventId);
|
||||||
eventRepo.delete(evtOpt.get());
|
eventRepo.delete(evt);
|
||||||
|
|
||||||
|
attendees.stream()
|
||||||
|
.map(LocationEventAttendeeEntity::getUserId)
|
||||||
|
.filter(uid -> !uid.equals(myId))
|
||||||
|
.forEach(uid -> systemMessageService.send(myId, uid, notifyText, targetUrl, MessageCause.EVENT_CANCELLED));
|
||||||
|
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,6 +297,78 @@ public class LocationEventController {
|
|||||||
return ResponseEntity.ok(Map.of("attending", attending, "attendeeCount", count));
|
return ResponseEntity.ok(Map.of("attending", attending, "attendeeCount", count));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Meine angemeldeten Events (für Home) ─────────────────────────────────
|
||||||
|
|
||||||
|
@GetMapping("/location-events/attending-next")
|
||||||
|
public ResponseEntity<List<EventPreviewDto>> getAttendingNext(Principal principal) {
|
||||||
|
UUID myId = userService.requireUser(principal).getUserId();
|
||||||
|
|
||||||
|
List<UUID> myEventIds = attendeeRepo.findByUserId(myId).stream()
|
||||||
|
.map(LocationEventAttendeeEntity::getEventId)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (myEventIds.isEmpty()) return ResponseEntity.ok(List.of());
|
||||||
|
|
||||||
|
Map<UUID, de.oaa.xxx.location.entity.LocationEntity> locationById = new java.util.HashMap<>();
|
||||||
|
|
||||||
|
List<EventPreviewDto> result = eventRepo
|
||||||
|
.findUpcomingByEventIds(myEventIds, LocalDateTime.now())
|
||||||
|
.stream()
|
||||||
|
.map(e -> {
|
||||||
|
var loc = locationById.computeIfAbsent(e.getLocationId(),
|
||||||
|
id -> locationRepo.findById(id).orElse(null));
|
||||||
|
String locName = loc != null ? loc.getName() : "";
|
||||||
|
return toPreview(e, locName, 0, 0, myId);
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Nächste Events je abonnierter Location (für Home) ────────────────────
|
||||||
|
|
||||||
|
@GetMapping("/location-events/followed-next")
|
||||||
|
public ResponseEntity<List<EventPreviewDto>> getFollowedNext(Principal principal) {
|
||||||
|
UUID myId = userService.requireUser(principal).getUserId();
|
||||||
|
|
||||||
|
List<UUID> followedIds = followRepo.findByUserId(myId).stream()
|
||||||
|
.map(LocationFollowEntity::getLocationId)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (followedIds.isEmpty()) return ResponseEntity.ok(List.of());
|
||||||
|
|
||||||
|
// Events ausschließen, bei denen der User bereits angemeldet ist
|
||||||
|
Set<UUID> attendingEventIds = attendeeRepo.findByUserId(myId).stream()
|
||||||
|
.map(LocationEventAttendeeEntity::getEventId)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
|
||||||
|
Map<UUID, de.oaa.xxx.location.entity.LocationEntity> locationById =
|
||||||
|
locationRepo.findAllById(followedIds).stream()
|
||||||
|
.collect(Collectors.toMap(
|
||||||
|
de.oaa.xxx.location.entity.LocationEntity::getLocationId,
|
||||||
|
l -> l));
|
||||||
|
|
||||||
|
// Ein Event pro Location: je das nächste, das noch nicht begonnen hat und nicht attending
|
||||||
|
List<EventPreviewDto> result = eventRepo
|
||||||
|
.findUpcomingByLocationIds(followedIds, LocalDateTime.now())
|
||||||
|
.stream()
|
||||||
|
.filter(e -> !attendingEventIds.contains(e.getEventId()))
|
||||||
|
.collect(Collectors.toMap(
|
||||||
|
LocationEventEntity::getLocationId,
|
||||||
|
e -> e,
|
||||||
|
(existing, replacement) -> existing))
|
||||||
|
.values().stream()
|
||||||
|
.sorted(Comparator.comparing(LocationEventEntity::getStartAt))
|
||||||
|
.map(e -> {
|
||||||
|
var loc = locationById.get(e.getLocationId());
|
||||||
|
String locName = loc != null ? loc.getName() : "";
|
||||||
|
return toPreview(e, locName, 0, 0, myId);
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Event-Suche (IDs + Batch) ─────────────────────────────────────────────
|
// ── Event-Suche (IDs + Batch) ─────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -257,7 +390,7 @@ public class LocationEventController {
|
|||||||
LocalDateTime fromDt = from != null ? LocalDateTime.parse(from) : LocalDateTime.now();
|
LocalDateTime fromDt = from != null ? LocalDateTime.parse(from) : LocalDateTime.now();
|
||||||
LocalDateTime toDt = to != null ? LocalDateTime.parse(to) : fromDt.plusMonths(3);
|
LocalDateTime toDt = to != null ? LocalDateTime.parse(to) : fromDt.plusMonths(3);
|
||||||
|
|
||||||
// Abonnierte Locations – deren Events werden immer eingeschlossen
|
// Abonnierte Locations - deren Events werden immer eingeschlossen
|
||||||
Set<UUID> followedLocationIds = followRepo.findByUserId(myId).stream()
|
Set<UUID> followedLocationIds = followRepo.findByUserId(myId).stream()
|
||||||
.map(LocationFollowEntity::getLocationId)
|
.map(LocationFollowEntity::getLocationId)
|
||||||
.collect(Collectors.toSet());
|
.collect(Collectors.toSet());
|
||||||
@@ -374,11 +507,12 @@ public class LocationEventController {
|
|||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
boolean attendingMe = attendeeRepo.findByEventIdAndUserId(e.getEventId(), myId).isPresent();
|
boolean attendingMe = attendeeRepo.findByEventIdAndUserId(e.getEventId(), myId).isPresent();
|
||||||
|
boolean isAdmin = isLocationAdmin(e.getLocationId(), myId);
|
||||||
|
|
||||||
return new EventDetailDto(
|
return new EventDetailDto(
|
||||||
e.getEventId(), e.getLocationId(), locationName,
|
e.getEventId(), e.getLocationId(), locationName,
|
||||||
e.getTitle(), e.getDescription(), e.getImageData(),
|
e.getTitle(), e.getDescription(), e.getImageData(),
|
||||||
e.getStartAt(), e.getCreatedAt(),
|
e.getStartAt(), e.getCreatedAt(),
|
||||||
attendingMe, attendees);
|
attendingMe, isAdmin, attendees);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package de.oaa.xxx.location.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "location_admin")
|
||||||
|
public class LocationAdminEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column
|
||||||
|
private UUID adminId;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private UUID locationId;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private UUID userId;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private LocalDateTime addedAt;
|
||||||
|
}
|
||||||
@@ -52,4 +52,8 @@ public class LocationEntity {
|
|||||||
|
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
/** Virtuelle Benutzer-ID für das Nachrichtensystem (einmalig generiert, unveränderlich) */
|
||||||
|
@Column(unique = true)
|
||||||
|
private UUID virtualUserId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package de.oaa.xxx.location.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "location_inbox_lock",
|
||||||
|
uniqueConstraints = @UniqueConstraint(columnNames = {"location_id", "visitor_id"}))
|
||||||
|
public class LocationInboxLockEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column
|
||||||
|
private UUID lockId;
|
||||||
|
|
||||||
|
@Column(name = "location_id", nullable = false)
|
||||||
|
private UUID locationId;
|
||||||
|
|
||||||
|
/** Die kontaktierende Person */
|
||||||
|
@Column(name = "visitor_id", nullable = false)
|
||||||
|
private UUID visitorId;
|
||||||
|
|
||||||
|
/** Der Admin/Inhaber, der gerade antwortet */
|
||||||
|
@Column(name = "locked_by_user_id", nullable = false)
|
||||||
|
private UUID lockedByUserId;
|
||||||
|
|
||||||
|
/** Letzte Aktivität des Lock-Inhabers (wird bei jeder Antwort erneuert) */
|
||||||
|
@Column(nullable = false)
|
||||||
|
private LocalDateTime lockedAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package de.oaa.xxx.location.repository;
|
||||||
|
|
||||||
|
import de.oaa.xxx.location.entity.LocationAdminEntity;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public interface LocationAdminRepository extends JpaRepository<LocationAdminEntity, UUID> {
|
||||||
|
|
||||||
|
List<LocationAdminEntity> findByLocationId(UUID locationId);
|
||||||
|
Optional<LocationAdminEntity> findByLocationIdAndUserId(UUID locationId, UUID userId);
|
||||||
|
boolean existsByLocationIdAndUserId(UUID locationId, UUID userId);
|
||||||
|
void deleteByLocationId(UUID locationId);
|
||||||
|
void deleteByLocationIdAndUserId(UUID locationId, UUID userId);
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ public interface LocationEventAttendeeRepository extends JpaRepository<LocationE
|
|||||||
|
|
||||||
List<LocationEventAttendeeEntity> findByEventIdOrderByRegisteredAtAsc(UUID eventId);
|
List<LocationEventAttendeeEntity> findByEventIdOrderByRegisteredAtAsc(UUID eventId);
|
||||||
|
|
||||||
|
List<LocationEventAttendeeEntity> findByUserId(UUID userId);
|
||||||
|
|
||||||
Optional<LocationEventAttendeeEntity> findByEventIdAndUserId(UUID eventId, UUID userId);
|
Optional<LocationEventAttendeeEntity> findByEventIdAndUserId(UUID eventId, UUID userId);
|
||||||
|
|
||||||
long countByEventId(UUID eventId);
|
long countByEventId(UUID eventId);
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ package de.oaa.xxx.location.repository;
|
|||||||
import de.oaa.xxx.location.entity.LocationEventEntity;
|
import de.oaa.xxx.location.entity.LocationEventEntity;
|
||||||
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 java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@@ -12,6 +14,12 @@ public interface LocationEventRepository extends JpaRepository<LocationEventEnti
|
|||||||
|
|
||||||
List<LocationEventEntity> findByLocationIdOrderByStartAtAsc(UUID locationId);
|
List<LocationEventEntity> findByLocationIdOrderByStartAtAsc(UUID locationId);
|
||||||
|
|
||||||
|
@Query("SELECT e FROM LocationEventEntity e WHERE e.locationId IN :locationIds AND e.startAt >= :from ORDER BY e.startAt ASC")
|
||||||
|
List<LocationEventEntity> findUpcomingByLocationIds(@Param("locationIds") Collection<UUID> locationIds, @Param("from") LocalDateTime from);
|
||||||
|
|
||||||
|
@Query("SELECT e FROM LocationEventEntity e WHERE e.eventId IN :eventIds AND e.startAt >= :from ORDER BY e.startAt ASC")
|
||||||
|
List<LocationEventEntity> findUpcomingByEventIds(@Param("eventIds") Collection<UUID> eventIds, @Param("from") LocalDateTime from);
|
||||||
|
|
||||||
/** Alle zukünftigen Events mit Koordinaten ihrer Location (für Umkreis-Suche) */
|
/** Alle zukünftigen Events mit Koordinaten ihrer Location (für Umkreis-Suche) */
|
||||||
@Query("""
|
@Query("""
|
||||||
SELECT e FROM LocationEventEntity e
|
SELECT e FROM LocationEventEntity e
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package de.oaa.xxx.location.repository;
|
||||||
|
|
||||||
|
import de.oaa.xxx.location.entity.LocationInboxLockEntity;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public interface LocationInboxLockRepository extends JpaRepository<LocationInboxLockEntity, UUID> {
|
||||||
|
|
||||||
|
Optional<LocationInboxLockEntity> findByLocationIdAndVisitorId(UUID locationId, UUID visitorId);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import de.oaa.xxx.location.entity.LocationEntity;
|
|||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
public interface LocationRepository extends JpaRepository<LocationEntity, UUID> {
|
public interface LocationRepository extends JpaRepository<LocationEntity, UUID> {
|
||||||
@@ -12,4 +13,8 @@ public interface LocationRepository extends JpaRepository<LocationEntity, UUID>
|
|||||||
|
|
||||||
/** Alle Locations mit gesetzten Koordinaten (für Umkreissuche) */
|
/** Alle Locations mit gesetzten Koordinaten (für Umkreissuche) */
|
||||||
List<LocationEntity> findByLatIsNotNullAndLonIsNotNull();
|
List<LocationEntity> findByLatIsNotNullAndLonIsNotNull();
|
||||||
|
|
||||||
|
Optional<LocationEntity> findByVirtualUserId(UUID virtualUserId);
|
||||||
|
|
||||||
|
List<LocationEntity> findByVirtualUserIdIsNotNull();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package de.oaa.xxx.social;
|
package de.oaa.xxx.social;
|
||||||
|
|
||||||
import de.oaa.xxx.dating.DatingMatchRepository;
|
import de.oaa.xxx.dating.DatingMatchRepository;
|
||||||
|
import de.oaa.xxx.location.entity.LocationEntity;
|
||||||
|
import de.oaa.xxx.location.repository.LocationAdminRepository;
|
||||||
|
import de.oaa.xxx.location.repository.LocationRepository;
|
||||||
import de.oaa.xxx.social.dto.ConversationSummary;
|
import de.oaa.xxx.social.dto.ConversationSummary;
|
||||||
import de.oaa.xxx.social.dto.FriendshipDto;
|
import de.oaa.xxx.social.dto.FriendshipDto;
|
||||||
import de.oaa.xxx.social.dto.MessageDto;
|
import de.oaa.xxx.social.dto.MessageDto;
|
||||||
@@ -43,6 +46,8 @@ public class SocialController {
|
|||||||
private final SseService sseService;
|
private final SseService sseService;
|
||||||
private final SystemMessageService systemMessageService;
|
private final SystemMessageService systemMessageService;
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
|
private final LocationRepository locationRepository;
|
||||||
|
private final LocationAdminRepository locationAdminRepository;
|
||||||
|
|
||||||
public SocialController(UserRepository userRepository,
|
public SocialController(UserRepository userRepository,
|
||||||
FriendshipRepository friendshipRepository,
|
FriendshipRepository friendshipRepository,
|
||||||
@@ -52,7 +57,9 @@ public class SocialController {
|
|||||||
SubscriptionLimitService subscriptionLimitService,
|
SubscriptionLimitService subscriptionLimitService,
|
||||||
SseService sseService,
|
SseService sseService,
|
||||||
SystemMessageService systemMessageService,
|
SystemMessageService systemMessageService,
|
||||||
UserService userService) {
|
UserService userService,
|
||||||
|
LocationRepository locationRepository,
|
||||||
|
LocationAdminRepository locationAdminRepository) {
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.friendshipRepository = friendshipRepository;
|
this.friendshipRepository = friendshipRepository;
|
||||||
this.messageRepository = messageRepository;
|
this.messageRepository = messageRepository;
|
||||||
@@ -62,6 +69,8 @@ public class SocialController {
|
|||||||
this.sseService = sseService;
|
this.sseService = sseService;
|
||||||
this.systemMessageService = systemMessageService;
|
this.systemMessageService = systemMessageService;
|
||||||
this.userService = userService;
|
this.userService = userService;
|
||||||
|
this.locationRepository = locationRepository;
|
||||||
|
this.locationAdminRepository = locationAdminRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
record FriendRequestBody(UUID receiverId) {}
|
record FriendRequestBody(UUID receiverId) {}
|
||||||
@@ -216,7 +225,8 @@ public class SocialController {
|
|||||||
|
|
||||||
@PostMapping("/messages")
|
@PostMapping("/messages")
|
||||||
public ResponseEntity<Map<String, String>> sendMessage(@RequestBody SendMessageBody body, Principal principal) {
|
public ResponseEntity<Map<String, String>> sendMessage(@RequestBody SendMessageBody body, Principal principal) {
|
||||||
UUID myId = userService.requireUser(principal).getUserId();
|
var me = userService.requireUser(principal);
|
||||||
|
UUID myId = me.getUserId();
|
||||||
|
|
||||||
if (body.text() == null || body.text().isBlank()) return ResponseEntity.badRequest().build();
|
if (body.text() == null || body.text().isBlank()) return ResponseEntity.badRequest().build();
|
||||||
|
|
||||||
@@ -225,6 +235,31 @@ public class SocialController {
|
|||||||
return ResponseEntity.status(403).build();
|
return ResponseEntity.status(403).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Location-Nachricht: keine Freundschafts-/Abo-Prüfung, alle Admins benachrichtigen
|
||||||
|
var locationOpt = locationRepository.findByVirtualUserId(body.receiverId());
|
||||||
|
if (locationOpt.isPresent()) {
|
||||||
|
LocationEntity loc = locationOpt.get();
|
||||||
|
MessageEntity msg = new MessageEntity();
|
||||||
|
msg.setMessageId(UUID.randomUUID());
|
||||||
|
msg.setSenderId(myId);
|
||||||
|
msg.setReceiverId(body.receiverId());
|
||||||
|
msg.setText(body.text().trim());
|
||||||
|
msg.setSentAt(LocalDateTime.now());
|
||||||
|
messageRepository.save(msg);
|
||||||
|
LOGGER.debug("User {} hat Location {} (virtualId {}) kontaktiert", myId, loc.getLocationId(), body.receiverId());
|
||||||
|
|
||||||
|
// Alle Admins + Inhaber per Systembenachrichtigung informieren
|
||||||
|
Set<UUID> adminIds = new java.util.LinkedHashSet<>();
|
||||||
|
adminIds.add(loc.getOwnerId());
|
||||||
|
locationAdminRepository.findByLocationId(loc.getLocationId()).forEach(a -> adminIds.add(a.getUserId()));
|
||||||
|
String notifText = me.getName() + " hat deine Location \"" + loc.getName() + "\" kontaktiert.";
|
||||||
|
String targetUrl = "/community/location-detail.html?id=" + loc.getLocationId() + "&chatWith=" + myId;
|
||||||
|
for (UUID adminId : adminIds) {
|
||||||
|
systemMessageService.send(myId, adminId, notifText, targetUrl, MessageCause.LOCATION_MESSAGE);
|
||||||
|
}
|
||||||
|
return ResponseEntity.status(201).build();
|
||||||
|
}
|
||||||
|
|
||||||
// Blockiert? (in beide Richtungen)
|
// Blockiert? (in beide Richtungen)
|
||||||
if (blockRepository.existsBlock(myId, body.receiverId())) {
|
if (blockRepository.existsBlock(myId, body.receiverId())) {
|
||||||
return ResponseEntity.status(403).body(Map.of("reason", "BLOCKED"));
|
return ResponseEntity.status(403).body(Map.of("reason", "BLOCKED"));
|
||||||
@@ -272,10 +307,20 @@ public class SocialController {
|
|||||||
for (Map.Entry<UUID, MessageEntity> entry : latestByPartner.entrySet()) {
|
for (Map.Entry<UUID, MessageEntity> entry : latestByPartner.entrySet()) {
|
||||||
UUID partnerId = entry.getKey();
|
UUID partnerId = entry.getKey();
|
||||||
MessageEntity lastMsg = entry.getValue();
|
MessageEntity lastMsg = entry.getValue();
|
||||||
var partnerOpt = userRepository.findById(partnerId);
|
|
||||||
if (partnerOpt.isEmpty()) continue;
|
|
||||||
|
|
||||||
UserProfile partnerProfile = toUserProfileWithStatus(partnerOpt.get(), myId);
|
UserProfile partnerProfile;
|
||||||
|
var userOpt = userRepository.findById(partnerId);
|
||||||
|
if (userOpt.isPresent()) {
|
||||||
|
partnerProfile = toUserProfileWithStatus(userOpt.get(), myId);
|
||||||
|
} else {
|
||||||
|
// Kein User → prüfen ob es eine Location-virtualUserId ist
|
||||||
|
var locOpt = locationRepository.findByVirtualUserId(partnerId);
|
||||||
|
if (locOpt.isEmpty()) continue;
|
||||||
|
LocationEntity loc = locOpt.get();
|
||||||
|
partnerProfile = new UserProfile(partnerId, loc.getName(),
|
||||||
|
loc.getProfilePictureLq(), loc.getProfilePictureHq(), "LOCATION");
|
||||||
|
}
|
||||||
|
|
||||||
MessageDto lastMsgDto = toMessageDto(lastMsg);
|
MessageDto lastMsgDto = toMessageDto(lastMsg);
|
||||||
long unreadCount = allMessages.stream()
|
long unreadCount = allMessages.stream()
|
||||||
.filter(m -> m.getSenderId().equals(partnerId)
|
.filter(m -> m.getSenderId().equals(partnerId)
|
||||||
@@ -433,7 +478,9 @@ public class SocialController {
|
|||||||
private MessageDto toMessageDto(MessageEntity m) {
|
private MessageDto toMessageDto(MessageEntity m) {
|
||||||
String senderName = userRepository.findById(m.getSenderId())
|
String senderName = userRepository.findById(m.getSenderId())
|
||||||
.map(UserEntity::getName)
|
.map(UserEntity::getName)
|
||||||
.orElse("Unbekannt");
|
.orElseGet(() -> locationRepository.findByVirtualUserId(m.getSenderId())
|
||||||
|
.map(LocationEntity::getName)
|
||||||
|
.orElse("Unbekannt"));
|
||||||
return new MessageDto(
|
return new MessageDto(
|
||||||
m.getMessageId(), m.getSenderId(), senderName,
|
m.getMessageId(), m.getSenderId(), senderName,
|
||||||
m.getReceiverId(), m.getText(), m.getSentAt(), m.getReadAt() != null);
|
m.getReceiverId(), m.getText(), m.getSentAt(), m.getReadAt() != null);
|
||||||
|
|||||||
@@ -57,9 +57,10 @@ public class SystemMessageService {
|
|||||||
.findByUserIdAndCause(receiverId, cause)
|
.findByUserIdAndCause(receiverId, cause)
|
||||||
.orElseGet(() -> NotificationPreferenceEntity.defaultFor(receiverId, cause));
|
.orElseGet(() -> NotificationPreferenceEntity.defaultFor(receiverId, cause));
|
||||||
|
|
||||||
// FRIENDREQUEST, INVITATION und DATE_INTEREST sind immer nur in-app, kein E-Mail
|
// Diese Causes sind immer nur in-app, kein E-Mail
|
||||||
boolean sendInApp = cause == MessageCause.FRIENDREQUEST || cause == MessageCause.INVITATION
|
boolean sendInApp = cause == MessageCause.FRIENDREQUEST || cause == MessageCause.INVITATION
|
||||||
|| cause == MessageCause.DATE_INTEREST || pref.isInApp();
|
|| cause == MessageCause.DATE_INTEREST || cause == MessageCause.LOCATION_MESSAGE
|
||||||
|
|| pref.isInApp();
|
||||||
|
|
||||||
if (sendInApp) {
|
if (sendInApp) {
|
||||||
MessageEntity msg = new MessageEntity();
|
MessageEntity msg = new MessageEntity();
|
||||||
@@ -109,6 +110,8 @@ public class SystemMessageService {
|
|||||||
case FRIENDREQUEST -> "XXX The Game – Neue Freundschaftsanfrage";
|
case FRIENDREQUEST -> "XXX The Game – Neue Freundschaftsanfrage";
|
||||||
case SUPPORT -> "xXx Sphere – Nachricht vom Support";
|
case SUPPORT -> "xXx Sphere – Nachricht vom Support";
|
||||||
case DATE_INTEREST -> "xXx Sphere – Interesse an deinem Date";
|
case DATE_INTEREST -> "xXx Sphere – Interesse an deinem Date";
|
||||||
|
case EVENT_CANCELLED -> "xXx Sphere – Veranstaltung abgesagt";
|
||||||
|
case LOCATION_MESSAGE -> "xXx Sphere – Neue Nachricht an deine Location";
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,5 +6,7 @@ public enum MessageCause {
|
|||||||
EMERGENCY,
|
EMERGENCY,
|
||||||
FRIENDREQUEST,
|
FRIENDREQUEST,
|
||||||
SUPPORT,
|
SUPPORT,
|
||||||
DATE_INTEREST
|
DATE_INTEREST,
|
||||||
|
EVENT_CANCELLED,
|
||||||
|
LOCATION_MESSAGE
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
.evt-date { font-size:0.88rem; color:var(--color-muted); margin-bottom:0.5rem; }
|
.evt-date { font-size:0.88rem; color:var(--color-muted); margin-bottom:0.5rem; }
|
||||||
.evt-desc { font-size:0.93rem; line-height:1.55; white-space:pre-wrap; word-break:break-word; margin-top:0.5rem; }
|
.evt-desc { font-size:0.93rem; line-height:1.55; white-space:pre-wrap; word-break:break-word; margin-top:0.5rem; }
|
||||||
|
|
||||||
.attend-btn { display:inline-flex; align-items:center; gap:0.4rem; margin-top:0.75rem; }
|
.attend-btn { display:inline-flex; align-items:center; gap:0.4rem; margin-top:0.75rem; flex-wrap:wrap; }
|
||||||
|
|
||||||
.section-title { font-size:1rem; font-weight:700; margin:1.5rem 0 0.75rem; }
|
.section-title { font-size:1rem; font-weight:700; margin:1.5rem 0 0.75rem; }
|
||||||
.gender-group { margin-bottom:1.25rem; }
|
.gender-group { margin-bottom:1.25rem; }
|
||||||
@@ -31,23 +31,92 @@
|
|||||||
.attendee-avatar { width:28px; height:28px; border-radius:50%; background:var(--color-secondary); object-fit:cover; flex-shrink:0; overflow:hidden; display:flex; align-items:center; justify-content:center; font-size:0.8rem; }
|
.attendee-avatar { width:28px; height:28px; border-radius:50%; background:var(--color-secondary); object-fit:cover; flex-shrink:0; overflow:hidden; display:flex; align-items:center; justify-content:center; font-size:0.8rem; }
|
||||||
.attendee-avatar img { width:100%; height:100%; object-fit:cover; }
|
.attendee-avatar img { width:100%; height:100%; object-fit:cover; }
|
||||||
.count-badge { background:var(--color-secondary); border-radius:12px; padding:0.15rem 0.6rem; font-size:0.78rem; color:var(--color-muted); margin-left:0.25rem; display:inline-block; }
|
.count-badge { background:var(--color-secondary); border-radius:12px; padding:0.15rem 0.6rem; font-size:0.78rem; color:var(--color-muted); margin-left:0.25rem; display:inline-block; }
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-overlay { display:none; position:fixed; inset:0; background:rgba(0,0,0,.6); z-index:200; align-items:center; justify-content:center; }
|
||||||
|
.modal-overlay.open { display:flex; }
|
||||||
|
.modal { background:var(--color-card); border-radius:12px; width:min(520px,95vw); max-height:90vh; overflow-y:auto; padding:1.5rem; }
|
||||||
|
.modal h3 { margin:0 0 1rem; }
|
||||||
|
.modal-footer { display:flex; gap:0.75rem; justify-content:flex-end; margin-top:1.25rem; flex-wrap:wrap; }
|
||||||
|
.img-preview { width:80px; height:80px; border-radius:8px; object-fit:cover; background:var(--color-secondary); display:flex; align-items:center; justify-content:center; font-size:1.5rem; flex-shrink:0; overflow:hidden; border:1px solid var(--color-secondary); }
|
||||||
|
.img-preview img { width:100%; height:100%; object-fit:cover; }
|
||||||
|
.img-row { display:flex; gap:0.75rem; align-items:center; margin-bottom:0.5rem; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="app">
|
||||||
<div class="main">
|
|
||||||
<a id="backLink" href="/community/events.html" class="back-link">← Veranstaltungen</a>
|
|
||||||
|
|
||||||
<div id="content">
|
<!-- ── Hinweis-Modal ──────────────────────────────────────────────────────────── -->
|
||||||
<p style="color:var(--color-muted);">Wird geladen…</p>
|
<div class="modal-overlay" id="alertModal">
|
||||||
|
<div class="modal" style="width:min(380px,95vw);">
|
||||||
|
<p id="alertMessage" style="margin:0 0 1.25rem;font-size:0.95rem;"></p>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn" onclick="document.getElementById('alertModal').classList.remove('open')">OK</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Bestätigungs-Modal ─────────────────────────────────────────────────────── -->
|
||||||
|
<div class="modal-overlay" id="confirmModal">
|
||||||
|
<div class="modal" style="width:min(380px,95vw);">
|
||||||
|
<h3 id="confirmTitle" style="margin:0 0 0.75rem;"></h3>
|
||||||
|
<p id="confirmMessage" style="margin:0 0 1.25rem;font-size:0.95rem;color:var(--color-muted);"></p>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn" style="background:var(--color-secondary);color:var(--color-text);" onclick="closeConfirm()">Abbrechen</button>
|
||||||
|
<button class="btn" id="confirmOkBtn" style="background:#c0392b;">Löschen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Edit-Modal ────────────────────────────────────────────────────────────── -->
|
||||||
|
<div class="modal-overlay" id="editModal">
|
||||||
|
<div class="modal">
|
||||||
|
<h3>Veranstaltung bearbeiten</h3>
|
||||||
|
<div class="img-row">
|
||||||
|
<div class="img-preview" id="editPicPreview">🗓</div>
|
||||||
|
<div>
|
||||||
|
<label class="btn" style="display:inline-block;cursor:pointer;font-size:0.85rem;">
|
||||||
|
Bild ändern
|
||||||
|
<input type="file" id="editPicFile" accept="image/*" style="display:none;" onchange="onEditPicChange(this)">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label>Titel *</label>
|
||||||
|
<input type="text" id="editTitle" maxlength="200">
|
||||||
|
<label>Beschreibung <span style="color:var(--color-muted);font-size:0.8rem;">(max. 1000 Zeichen)</span></label>
|
||||||
|
<textarea id="editDesc" maxlength="1000" rows="4"
|
||||||
|
style="resize:vertical;width:100%;box-sizing:border-box;padding:0.65rem 0.9rem;border:1px solid var(--color-secondary);border-radius:6px;background:var(--color-secondary);color:var(--color-text);font-size:1rem;outline:none;font-family:inherit;transition:border-color 0.2s;"
|
||||||
|
onfocus="this.style.borderColor='var(--color-primary)'" onblur="this.style.borderColor='var(--color-secondary)'"></textarea>
|
||||||
|
<label>Datum & Uhrzeit *</label>
|
||||||
|
<input type="datetime-local" id="editStartAt">
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn" style="background:var(--color-secondary);color:var(--color-text);" onclick="closeEditModal()">Abbrechen</button>
|
||||||
|
<button class="btn" id="editSubmitBtn" onclick="submitEdit()">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="main">
|
||||||
|
<div class="content">
|
||||||
|
<a id="backLink" href="/community/events.html" class="back-link">← Veranstaltungen</a>
|
||||||
|
<div id="content">
|
||||||
|
<p style="color:var(--color-muted);">Wird geladen…</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/sidebar.js"></script>
|
|
||||||
<script src="/js/icons.js"></script>
|
<script src="/js/icons.js"></script>
|
||||||
|
<script src="/js/sidebar.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
const eventId = params.get('id');
|
const eventId = params.get('id');
|
||||||
let myUserId = null;
|
let myUserId = null;
|
||||||
|
let _evtData = null;
|
||||||
|
let _editImg = null;
|
||||||
|
|
||||||
|
function showAlert(msg) {
|
||||||
|
document.getElementById('alertMessage').textContent = msg;
|
||||||
|
document.getElementById('alertModal').classList.add('open');
|
||||||
|
}
|
||||||
|
|
||||||
function escHtml(s) {
|
function escHtml(s) {
|
||||||
return (s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
return (s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
@@ -78,17 +147,16 @@ async function loadPage() {
|
|||||||
if (!evtRes.ok) { document.getElementById('content').innerHTML = '<p>Veranstaltung nicht gefunden.</p>'; return; }
|
if (!evtRes.ok) { document.getElementById('content').innerHTML = '<p>Veranstaltung nicht gefunden.</p>'; return; }
|
||||||
if (meRes.ok) { const me = await meRes.json(); myUserId = me.userId; }
|
if (meRes.ok) { const me = await meRes.json(); myUserId = me.userId; }
|
||||||
|
|
||||||
const evt = await evtRes.json();
|
_evtData = await evtRes.json();
|
||||||
|
|
||||||
// Rücklink zur Location
|
|
||||||
const backLink = document.getElementById('backLink');
|
const backLink = document.getElementById('backLink');
|
||||||
if (evt.locationId) {
|
if (_evtData.locationId) {
|
||||||
backLink.href = `/community/location-detail.html?id=${evt.locationId}`;
|
backLink.href = `/community/location-detail.html?id=${_evtData.locationId}`;
|
||||||
backLink.textContent = `← ${escHtml(evt.locationName) || 'Location'}`;
|
backLink.textContent = `← ${_evtData.locationName || 'Location'}`;
|
||||||
}
|
}
|
||||||
document.title = `${evt.title} – xXx Sphere`;
|
document.title = `${_evtData.title} – xXx Sphere`;
|
||||||
|
|
||||||
renderPage(evt);
|
renderPage(_evtData);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPage(evt) {
|
function renderPage(evt) {
|
||||||
@@ -96,7 +164,9 @@ function renderPage(evt) {
|
|||||||
? `<img src="data:image/jpeg;base64,${evt.imageData}" alt="${escHtml(evt.title)}">`
|
? `<img src="data:image/jpeg;base64,${evt.imageData}" alt="${escHtml(evt.title)}">`
|
||||||
: '🗓';
|
: '🗓';
|
||||||
|
|
||||||
// Teilnehmende nach Geschlecht gruppieren
|
const isFuture = new Date(evt.startAt) > new Date();
|
||||||
|
const canEdit = !!evt.isAdmin && isFuture;
|
||||||
|
|
||||||
const byGender = {};
|
const byGender = {};
|
||||||
(evt.attendees || []).forEach(a => {
|
(evt.attendees || []).forEach(a => {
|
||||||
const g = a.geschlecht || 'UNBEKANNT';
|
const g = a.geschlecht || 'UNBEKANNT';
|
||||||
@@ -134,7 +204,13 @@ function renderPage(evt) {
|
|||||||
${evt.locationName ? `<div class="evt-location">📍 <a href="/community/location-detail.html?id=${evt.locationId}" style="color:inherit;text-decoration:none;">${escHtml(evt.locationName)}</a></div>` : ''}
|
${evt.locationName ? `<div class="evt-location">📍 <a href="/community/location-detail.html?id=${evt.locationId}" style="color:inherit;text-decoration:none;">${escHtml(evt.locationName)}</a></div>` : ''}
|
||||||
<div class="evt-date">🗓 ${formatDate(evt.startAt)}</div>
|
<div class="evt-date">🗓 ${formatDate(evt.startAt)}</div>
|
||||||
${evt.description ? `<div class="evt-desc">${escHtml(evt.description)}</div>` : ''}
|
${evt.description ? `<div class="evt-desc">${escHtml(evt.description)}</div>` : ''}
|
||||||
<div class="attend-btn">
|
<div style="display:flex;flex-direction:column;gap:0.5rem;margin-top:0.75rem;">
|
||||||
|
${canEdit ? `
|
||||||
|
<div style="display:flex;align-items:center;gap:0.4rem;flex-wrap:wrap;">
|
||||||
|
<button class="btn" style="background:var(--color-secondary);color:var(--color-text);font-size:0.85rem;" onclick="openEditModal()">✎ Bearbeiten</button>
|
||||||
|
<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"
|
<button class="btn" id="attendBtn"
|
||||||
style="${attending ? 'background:var(--color-secondary);color:var(--color-text);' : ''}"
|
style="${attending ? 'background:var(--color-secondary);color:var(--color-text);' : ''}"
|
||||||
onclick="toggleAttend()">
|
onclick="toggleAttend()">
|
||||||
@@ -144,6 +220,7 @@ function renderPage(evt) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
${totalAttendees > 0 ? `
|
${totalAttendees > 0 ? `
|
||||||
<div class="section-title">Teilnehmende</div>
|
<div class="section-title">Teilnehmende</div>
|
||||||
@@ -154,9 +231,8 @@ function renderPage(evt) {
|
|||||||
|
|
||||||
async function toggleAttend() {
|
async function toggleAttend() {
|
||||||
const res = await fetch(`/location-events/${eventId}/attend`, { method: 'POST' });
|
const res = await fetch(`/location-events/${eventId}/attend`, { method: 'POST' });
|
||||||
if (!res.ok) { alert('Fehler beim Aktualisieren.'); return; }
|
if (!res.ok) { showAlert('Fehler beim Aktualisieren.'); return; }
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
const btn = document.getElementById('attendBtn');
|
const btn = document.getElementById('attendBtn');
|
||||||
const countEl = document.getElementById('attendCount');
|
const countEl = document.getElementById('attendCount');
|
||||||
if (btn) {
|
if (btn) {
|
||||||
@@ -165,10 +241,107 @@ async function toggleAttend() {
|
|||||||
btn.style.color = data.attending ? 'var(--color-text)' : '';
|
btn.style.color = data.attending ? 'var(--color-text)' : '';
|
||||||
}
|
}
|
||||||
if (countEl) countEl.textContent = `${data.attendeeCount} Teilnehmer*in(nen)`;
|
if (countEl) countEl.textContent = `${data.attendeeCount} Teilnehmer*in(nen)`;
|
||||||
|
|
||||||
// Teilnehmendenliste neu laden
|
|
||||||
const evtRes = await fetch(`/location-events/${eventId}`);
|
const evtRes = await fetch(`/location-events/${eventId}`);
|
||||||
if (evtRes.ok) { renderPage(await evtRes.json()); }
|
if (evtRes.ok) { _evtData = await evtRes.json(); renderPage(_evtData); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Edit Modal ────────────────────────────────────────────────────────────────
|
||||||
|
function resizeImage(file, maxPx, quality) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
img.onload = () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
let w = img.naturalWidth, h = img.naturalHeight;
|
||||||
|
if (w > maxPx || h > maxPx) {
|
||||||
|
if (w >= h) { h = Math.max(1, Math.round(maxPx * h / w)); w = maxPx; }
|
||||||
|
else { w = Math.max(1, Math.round(maxPx * w / h)); h = maxPx; }
|
||||||
|
}
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = w; canvas.height = h;
|
||||||
|
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
|
||||||
|
resolve(canvas.toDataURL('image/jpeg', quality || 0.85).split(',')[1]);
|
||||||
|
};
|
||||||
|
img.onerror = reject;
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditModal() {
|
||||||
|
if (!_evtData) return;
|
||||||
|
_editImg = null;
|
||||||
|
document.getElementById('editTitle').value = _evtData.title || '';
|
||||||
|
document.getElementById('editDesc').value = _evtData.description || '';
|
||||||
|
const nowLocal = new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().slice(0, 16);
|
||||||
|
const startVal = _evtData.startAt ? _evtData.startAt.slice(0, 16) : '';
|
||||||
|
document.getElementById('editStartAt').min = nowLocal;
|
||||||
|
document.getElementById('editStartAt').value = startVal;
|
||||||
|
const preview = document.getElementById('editPicPreview');
|
||||||
|
preview.innerHTML = _evtData.imageData
|
||||||
|
? `<img src="data:image/jpeg;base64,${_evtData.imageData}" alt="">`
|
||||||
|
: '🗓';
|
||||||
|
document.getElementById('editModal').classList.add('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditModal() { document.getElementById('editModal').classList.remove('open'); }
|
||||||
|
|
||||||
|
async function onEditPicChange(input) {
|
||||||
|
const file = input.files[0]; if (!file) return;
|
||||||
|
_editImg = await resizeImage(file, 1024, 0.88);
|
||||||
|
document.getElementById('editPicPreview').innerHTML = `<img src="data:image/jpeg;base64,${_editImg}" alt="">`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitEdit() {
|
||||||
|
const title = document.getElementById('editTitle').value.trim();
|
||||||
|
const desc = document.getElementById('editDesc').value;
|
||||||
|
const startAt = document.getElementById('editStartAt').value;
|
||||||
|
if (!title) { showAlert('Bitte gib einen Titel ein.'); return; }
|
||||||
|
if (!startAt) { showAlert('Bitte wähle Datum und Uhrzeit.'); return; }
|
||||||
|
if (new Date(startAt) <= new Date()) { showAlert('Der Termin muss in der Zukunft liegen.'); return; }
|
||||||
|
|
||||||
|
const btn = document.getElementById('editSubmitBtn');
|
||||||
|
btn.disabled = true; btn.textContent = 'Wird gespeichert…';
|
||||||
|
try {
|
||||||
|
const body = { title, description: desc };
|
||||||
|
if (startAt) body.startAt = startAt + ':00';
|
||||||
|
if (_editImg) body.imageData = _editImg;
|
||||||
|
|
||||||
|
const res = await fetch(`/locations/${_evtData.locationId}/events/${eventId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
if (res.status === 422) { showAlert('Der Termin muss in der Zukunft liegen.'); return; }
|
||||||
|
if (!res.ok) { showAlert('Fehler beim Speichern.'); return; }
|
||||||
|
_evtData = await res.json();
|
||||||
|
closeEditModal();
|
||||||
|
renderPage(_evtData);
|
||||||
|
document.title = `${_evtData.title} – xXx Sphere`;
|
||||||
|
} catch { showAlert('Fehler beim Speichern.'); }
|
||||||
|
finally { btn.disabled = false; btn.textContent = 'Speichern'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Confirm / Delete ──────────────────────────────────────────────────────────
|
||||||
|
let _confirmCallback = null;
|
||||||
|
function openConfirm(title, message, onOk) {
|
||||||
|
document.getElementById('confirmTitle').textContent = title;
|
||||||
|
document.getElementById('confirmMessage').textContent = message;
|
||||||
|
_confirmCallback = onOk;
|
||||||
|
document.getElementById('confirmModal').classList.add('open');
|
||||||
|
}
|
||||||
|
function closeConfirm() { document.getElementById('confirmModal').classList.remove('open'); _confirmCallback = null; }
|
||||||
|
document.getElementById('confirmOkBtn').addEventListener('click', () => { if (_confirmCallback) _confirmCallback(); closeConfirm(); });
|
||||||
|
|
||||||
|
function openDeleteConfirm() {
|
||||||
|
openConfirm(
|
||||||
|
'Veranstaltung löschen',
|
||||||
|
'Soll diese Veranstaltung wirklich gelöscht werden? Alle angemeldeten Teilnehmenden werden benachrichtigt.',
|
||||||
|
async () => {
|
||||||
|
const res = await fetch(`/locations/${_evtData.locationId}/events/${eventId}`, { method: 'DELETE' });
|
||||||
|
if (!res.ok) { showAlert('Fehler beim Löschen.'); return; }
|
||||||
|
location.href = `/community/location-detail.html?id=${_evtData.locationId}`;
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
loadPage();
|
loadPage();
|
||||||
|
|||||||
@@ -273,7 +273,15 @@
|
|||||||
: '';
|
: '';
|
||||||
|
|
||||||
const gruppeIdAttr = p.gruppeId ? ` data-gruppe-id="${p.gruppeId}"` : '';
|
const gruppeIdAttr = p.gruppeId ? ` data-gruppe-id="${p.gruppeId}"` : '';
|
||||||
return `<div class="post-card" id="pc-${p.postId}"${gruppeIdAttr} onclick="openLb('${p.postId}','${p.postType}')" style="cursor:pointer;">
|
const hasTarget = !!p.targetUrl;
|
||||||
|
const cardClick = hasTarget
|
||||||
|
? `window.location.href='${p.targetUrl}'`
|
||||||
|
: `openLb('${p.postId}','${p.postType}')`;
|
||||||
|
const commentBtn = hasTarget ? '' : `
|
||||||
|
<button class="post-action-btn" onclick="event.stopPropagation(); openLb('${p.postId}','${p.postType}')">
|
||||||
|
💬 <span id="kc-${p.postId}">${p.kommentarCount}</span>
|
||||||
|
</button>`;
|
||||||
|
return `<div class="post-card" id="pc-${p.postId}"${gruppeIdAttr} onclick="${cardClick}" style="cursor:pointer;">
|
||||||
<div class="post-header">
|
<div class="post-header">
|
||||||
<div class="post-avatar">${avatarHtml}</div>
|
<div class="post-avatar">${avatarHtml}</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -289,9 +297,7 @@
|
|||||||
<button class="post-action-btn${p.likedByMe ? ' active' : ''}" id="lk-${p.postId}" onclick="event.stopPropagation(); likePost('${p.postId}','${p.postType}')">
|
<button class="post-action-btn${p.likedByMe ? ' active' : ''}" id="lk-${p.postId}" onclick="event.stopPropagation(); likePost('${p.postId}','${p.postType}')">
|
||||||
♥ <span id="lkc-${p.postId}">${p.likeCount}</span>
|
♥ <span id="lkc-${p.postId}">${p.likeCount}</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="post-action-btn" onclick="event.stopPropagation(); openLb('${p.postId}','${p.postType}')">
|
${commentBtn}
|
||||||
💬 <span id="kc-${p.postId}">${p.kommentarCount}</span>
|
|
||||||
</button>
|
|
||||||
${meldenBtn}
|
${meldenBtn}
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
.back-link:hover { color:var(--color-primary); }
|
.back-link:hover { color:var(--color-primary); }
|
||||||
|
|
||||||
.loc-header { display:flex; gap:1rem; align-items:flex-start; margin-bottom:1.25rem; flex-wrap:wrap; }
|
.loc-header { display:flex; gap:1rem; align-items:flex-start; margin-bottom:1.25rem; flex-wrap:wrap; }
|
||||||
.loc-avatar { width:96px; height:96px; border-radius:12px; background:var(--color-secondary); object-fit:cover; flex-shrink:0; display:flex; align-items:center; justify-content:center; font-size:2.5rem; overflow:hidden; border:2px solid var(--color-secondary); }
|
.loc-avatar { width:120px; height:120px; border-radius:12px; background:var(--color-secondary); object-fit:cover; flex-shrink:0; display:flex; align-items:center; justify-content:center; font-size:2.5rem; overflow:hidden; border:2px solid var(--color-secondary); }
|
||||||
.loc-avatar img { width:100%; height:100%; object-fit:cover; }
|
.loc-avatar img { width:100%; height:100%; object-fit:cover; }
|
||||||
.loc-meta { flex:1; min-width:0; }
|
.loc-meta { flex:1; min-width:0; }
|
||||||
.loc-name { font-size:1.4rem; font-weight:700; margin:0 0 0.3rem; }
|
.loc-name { font-size:1.4rem; font-weight:700; margin:0 0 0.3rem; }
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
.hours-table td:first-child { font-weight:500; width:100px; }
|
.hours-table td:first-child { font-weight:500; width:100px; }
|
||||||
.hours-closed { color:var(--color-muted); }
|
.hours-closed { color:var(--color-muted); }
|
||||||
|
|
||||||
.gallery-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(120px,1fr)); gap:0.6rem; }
|
.gallery-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(180px,1fr)); gap:0.6rem; }
|
||||||
.gallery-img-wrap { position:relative; aspect-ratio:1; border-radius:8px; overflow:hidden; background:var(--color-secondary); }
|
.gallery-img-wrap { position:relative; aspect-ratio:1; border-radius:8px; overflow:hidden; background:var(--color-secondary); }
|
||||||
.gallery-img-wrap img { width:100%; height:100%; object-fit:cover; cursor:pointer; transition:opacity 0.15s; }
|
.gallery-img-wrap img { width:100%; height:100%; object-fit:cover; cursor:pointer; transition:opacity 0.15s; }
|
||||||
.gallery-img-wrap img:hover { opacity:0.88; }
|
.gallery-img-wrap img:hover { opacity:0.88; }
|
||||||
@@ -59,20 +59,73 @@
|
|||||||
/* Lightbox */
|
/* Lightbox */
|
||||||
.lb { display:none; position:fixed; inset:0; background:rgba(0,0,0,.9); z-index:300; align-items:center; justify-content:center; }
|
.lb { display:none; position:fixed; inset:0; background:rgba(0,0,0,.9); z-index:300; align-items:center; justify-content:center; }
|
||||||
.lb.open { display:flex; }
|
.lb.open { display:flex; }
|
||||||
.lb img { max-width:95vw; max-height:95vh; border-radius:8px; object-fit:contain; }
|
.lb img { max-width:85vw; max-height:90vh; border-radius:8px; object-fit:contain; }
|
||||||
.lb-close { position:absolute; top:1rem; right:1rem; background:none; border:none; color:#fff; font-size:1.5rem; cursor:pointer; }
|
.lb-close { position:absolute; top:1rem; right:1rem; background:none; border:none; color:#fff; font-size:1.5rem; cursor:pointer; }
|
||||||
|
.lb-nav { position:absolute; top:50%; transform:translateY(-50%); background:rgba(255,255,255,.15); border:none; color:#fff; font-size:2.5rem; line-height:1; cursor:pointer; padding:0.3rem 0.8rem; border-radius:8px; transition:background 0.15s; user-select:none; }
|
||||||
|
.lb-nav:hover { background:rgba(255,255,255,.3); }
|
||||||
|
.lb-nav:disabled { opacity:0.2; cursor:default; }
|
||||||
|
.lb-prev { left:1rem; }
|
||||||
|
.lb-next { right:1rem; }
|
||||||
|
|
||||||
.owner-badge { display:inline-flex; align-items:center; gap:0.3rem; font-size:0.75rem; background:var(--color-secondary); border-radius:4px; padding:0.2rem 0.5rem; color:var(--color-muted); margin-top:0.3rem; }
|
.owner-badge { display:inline-flex; align-items:center; gap:0.3rem; font-size:0.75rem; background:var(--color-secondary); border-radius:4px; padding:0.2rem 0.5rem; color:var(--color-muted); margin-top:0.3rem; }
|
||||||
.owner-actions { display:flex; gap:0.5rem; flex-wrap:wrap; margin-top:0.75rem; }
|
.owner-actions { display:flex; gap:0.5rem; flex-wrap:wrap; margin-top:0.75rem; }
|
||||||
|
|
||||||
|
/* Tabs */
|
||||||
|
.tab-bar { display:flex; gap:0; border-bottom:2px solid var(--color-secondary); margin-bottom:1.25rem; flex-wrap:wrap; }
|
||||||
|
.tab-btn { background:none; border:none; border-bottom:2px solid transparent; color:var(--color-muted); cursor:pointer; font-size:0.88rem; font-weight:600; padding:0.65rem 1rem; margin-bottom:-2px; border-radius:0; width:auto; margin-top:0; transition:color 0.15s, border-color 0.15s; white-space:nowrap; }
|
||||||
|
.tab-btn:hover { color:var(--color-text); background:none; }
|
||||||
|
.tab-btn.active { color:var(--color-text); border-bottom-color:var(--color-primary); }
|
||||||
|
.tab-panel { display:none; }
|
||||||
|
.tab-panel.active { display:block; }
|
||||||
|
|
||||||
|
/* Posteingang */
|
||||||
|
.inbox-list { display:flex; flex-direction:column; gap:0.5rem; }
|
||||||
|
.inbox-item { display:flex; align-items:center; gap:0.65rem; padding:0.65rem 0.75rem; border-radius:8px; background:var(--color-secondary); cursor:pointer; transition:opacity 0.15s; }
|
||||||
|
.inbox-item:hover { opacity:0.85; }
|
||||||
|
.inbox-avatar { width:36px; height:36px; border-radius:50%; background:var(--color-card); display:flex; align-items:center; justify-content:center; font-size:0.9rem; flex-shrink:0; overflow:hidden; }
|
||||||
|
.inbox-avatar img { width:100%; height:100%; object-fit:cover; }
|
||||||
|
.inbox-info { flex:1; min-width:0; }
|
||||||
|
.inbox-name { font-weight:600; font-size:0.88rem; }
|
||||||
|
.inbox-preview { font-size:0.78rem; color:var(--color-muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||||
|
.inbox-unread { background:var(--color-primary); color:#fff; font-size:0.65rem; font-weight:700; border-radius:9999px; padding:0.1rem 0.35rem; flex-shrink:0; }
|
||||||
|
|
||||||
|
/* Posteingang Chat */
|
||||||
|
.inbox-chat { display:none; flex-direction:column; gap:0; margin-top:0.75rem; border:1px solid var(--color-secondary); border-radius:10px; overflow:hidden; }
|
||||||
|
.inbox-chat.open { display:flex; }
|
||||||
|
.inbox-chat-header { display:flex; align-items:center; gap:0.5rem; padding:0.65rem 0.9rem; background:var(--color-secondary); font-weight:600; font-size:0.88rem; }
|
||||||
|
.inbox-chat-back { background:none; border:none; color:var(--color-muted); cursor:pointer; font-size:1.1rem; padding:0; margin:0; width:auto; line-height:1; }
|
||||||
|
.inbox-chat-close { background:none; border:none; color:var(--color-muted); cursor:pointer; font-size:1rem; padding:0.15rem 0.35rem; margin:0 0 0 auto; width:auto; line-height:1; border-radius:4px; transition:background 0.15s, color 0.15s; }
|
||||||
|
.inbox-chat-close:hover { background:var(--color-card); color:var(--color-text); }
|
||||||
|
.inbox-chat-messages { max-height:320px; overflow-y:auto; padding:0.75rem 1rem; display:flex; flex-direction:column; gap:0.4rem; }
|
||||||
|
.inbox-bubble-wrap { display:flex; flex-direction:column; }
|
||||||
|
.inbox-bubble-wrap.me { align-items:flex-end; }
|
||||||
|
.inbox-bubble-wrap.them { align-items:flex-start; }
|
||||||
|
.inbox-bubble { max-width:75%; padding:0.45rem 0.8rem; border-radius:12px; font-size:0.88rem; line-height:1.4; word-break:break-word; }
|
||||||
|
.inbox-bubble-wrap.me .inbox-bubble { background:var(--color-primary); color:#fff; border-bottom-right-radius:4px; }
|
||||||
|
.inbox-bubble-wrap.them .inbox-bubble { background:var(--color-secondary); color:var(--color-text); border-bottom-left-radius:4px; }
|
||||||
|
.inbox-bubble-time { font-size:0.68rem; color:var(--color-muted); margin-top:0.1rem; padding:0 0.2rem; }
|
||||||
|
.inbox-reply-area { display:flex; gap:0.5rem; padding:0.6rem 0.9rem; border-top:1px solid var(--color-secondary); align-items:center; }
|
||||||
|
.inbox-reply-area input { flex:1; }
|
||||||
|
.inbox-reply-btn { width:auto; margin-top:0; padding:0.5rem 1rem; flex-shrink:0; }
|
||||||
|
.inbox-lock-hint { font-size:0.8rem; color:var(--color-muted); padding:0.5rem 0.9rem; border-top:1px solid var(--color-secondary); background:var(--color-secondary); }
|
||||||
|
.inbox-reply-trigger { padding:0.6rem 0.9rem; border-top:1px solid var(--color-secondary); }
|
||||||
|
.inbox-reply-trigger .btn { font-size:0.85rem; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="app">
|
||||||
|
|
||||||
|
<!-- ── Admin-Autocomplete (außerhalb .main, damit overflow-y:auto nicht clippt) ── -->
|
||||||
|
<ul id="adminSearchList" style="position:fixed;display:none;background:var(--color-card);border:1px solid var(--color-secondary);border-radius:6px;z-index:400;list-style:none;margin:0;padding:0;max-height:200px;overflow-y:auto;box-shadow:0 4px 12px rgba(0,0,0,.15);"></ul>
|
||||||
|
<ul id="ownerSearchList" style="position:fixed;display:none;background:var(--color-card);border:1px solid var(--color-secondary);border-radius:6px;z-index:400;list-style:none;margin:0;padding:0;max-height:200px;overflow-y:auto;box-shadow:0 4px 12px rgba(0,0,0,.15);"></ul>
|
||||||
|
|
||||||
<div class="main">
|
<div class="main">
|
||||||
|
<div class="content">
|
||||||
<a href="/community/locations.html" class="back-link">← Locations</a>
|
<a href="/community/locations.html" class="back-link">← Locations</a>
|
||||||
|
|
||||||
<div id="content">
|
<div id="content">
|
||||||
<p style="color:var(--color-muted);">Wird geladen…</p>
|
<p style="color:var(--color-muted);">Wird geladen…</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Edit-Modal ──────────────────────────────────────────────────────────── -->
|
<!-- ── Edit-Modal ──────────────────────────────────────────────────────────── -->
|
||||||
@@ -131,7 +184,7 @@
|
|||||||
<label>Titel *</label>
|
<label>Titel *</label>
|
||||||
<input type="text" id="eventTitle" maxlength="200">
|
<input type="text" id="eventTitle" maxlength="200">
|
||||||
<label>Beschreibung <span style="color:var(--color-muted);font-size:0.8rem;">(max. 1000 Zeichen)</span></label>
|
<label>Beschreibung <span style="color:var(--color-muted);font-size:0.8rem;">(max. 1000 Zeichen)</span></label>
|
||||||
<textarea id="eventDesc" maxlength="1000" rows="4" style="resize:vertical;"></textarea>
|
<textarea id="eventDesc" maxlength="1000" rows="4" style="resize:vertical;width:100%;box-sizing:border-box;padding:0.65rem 0.9rem;border:1px solid var(--color-secondary);border-radius:6px;background:var(--color-secondary);color:var(--color-text);font-size:1rem;outline:none;font-family:inherit;transition:border-color 0.2s;" onfocus="this.style.borderColor='var(--color-primary)'" onblur="this.style.borderColor='var(--color-secondary)'"></textarea>
|
||||||
<label>Datum & Uhrzeit *</label>
|
<label>Datum & Uhrzeit *</label>
|
||||||
<input type="datetime-local" id="eventStartAt">
|
<input type="datetime-local" id="eventStartAt">
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
@@ -141,20 +194,45 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Hinweis-Modal ──────────────────────────────────────────────────────── -->
|
||||||
|
<div class="modal-overlay" id="alertModal">
|
||||||
|
<div class="modal" style="width:min(380px,95vw);">
|
||||||
|
<p id="alertMessage" style="margin:0 0 1.25rem;font-size:0.95rem;"></p>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn" onclick="document.getElementById('alertModal').classList.remove('open')">OK</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Bestätigungs-Modal ─────────────────────────────────────────────────── -->
|
||||||
|
<div class="modal-overlay" id="confirmModal">
|
||||||
|
<div class="modal" style="width:min(380px,95vw);">
|
||||||
|
<h3 id="confirmTitle">Bestätigung</h3>
|
||||||
|
<p id="confirmMessage" style="color:var(--color-muted);font-size:0.92rem;margin:0 0 0.25rem;"></p>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn" style="background:var(--color-secondary);color:var(--color-text);" onclick="closeConfirm()">Abbrechen</button>
|
||||||
|
<button class="btn" id="confirmOkBtn" style="background:#c0392b;">Löschen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ── Galerie Lightbox ───────────────────────────────────────────────────── -->
|
<!-- ── Galerie Lightbox ───────────────────────────────────────────────────── -->
|
||||||
<div class="lb" id="lightbox" onclick="closeLightbox()">
|
<div class="lb" id="lightbox" onclick="closeLightbox()">
|
||||||
<button class="lb-close" onclick="closeLightbox()">✕</button>
|
<button class="lb-close" onclick="closeLightbox()">✕</button>
|
||||||
<img id="lbImg" src="" alt="">
|
<button class="lb-nav lb-prev" id="lbPrev" onclick="event.stopPropagation();lbNav(-1)">‹</button>
|
||||||
|
<img id="lbImg" src="" alt="" onclick="event.stopPropagation()">
|
||||||
|
<button class="lb-nav lb-next" id="lbNext" onclick="event.stopPropagation();lbNav(1)">›</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/sidebar.js"></script>
|
|
||||||
<script src="/js/icons.js"></script>
|
<script src="/js/icons.js"></script>
|
||||||
|
<script src="/js/sidebar.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
const locationId = params.get('id');
|
const locationId = params.get('id');
|
||||||
let locDetail = null;
|
let locDetail = null;
|
||||||
let myUserId = null;
|
let myUserId = null;
|
||||||
let isOwner = false;
|
let isOwner = false;
|
||||||
|
let isAdmin = false;
|
||||||
let isFollowing = false;
|
let isFollowing = false;
|
||||||
|
|
||||||
// ── Bild-Resize ───────────────────────────────────────────────────────────────
|
// ── Bild-Resize ───────────────────────────────────────────────────────────────
|
||||||
@@ -192,6 +270,18 @@ function formatDate(dt) {
|
|||||||
+ ', ' + d.toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' }) + ' Uhr';
|
+ ', ' + d.toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' }) + ' Uhr';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Tabs ──────────────────────────────────────────────────────────────────────
|
||||||
|
const VALID_TABS = ['grunddaten', 'admins', 'posteingang', 'veranstaltungen'];
|
||||||
|
|
||||||
|
function switchTab(name) {
|
||||||
|
if (!VALID_TABS.includes(name)) name = 'grunddaten';
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(btn =>
|
||||||
|
btn.classList.toggle('active', btn.dataset.tab === name));
|
||||||
|
document.querySelectorAll('.tab-panel').forEach(panel =>
|
||||||
|
panel.classList.toggle('active', panel.id === 'tab-' + name));
|
||||||
|
history.replaceState(null, '', location.pathname + location.search + '#' + name);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Lade Seite ────────────────────────────────────────────────────────────────
|
// ── Lade Seite ────────────────────────────────────────────────────────────────
|
||||||
async function loadPage() {
|
async function loadPage() {
|
||||||
if (!locationId) { document.getElementById('content').innerHTML = '<p>Keine Location-ID angegeben.</p>'; return; }
|
if (!locationId) { document.getElementById('content').innerHTML = '<p>Keine Location-ID angegeben.</p>'; return; }
|
||||||
@@ -209,10 +299,22 @@ async function loadPage() {
|
|||||||
|
|
||||||
locDetail = await locRes.json();
|
locDetail = await locRes.json();
|
||||||
isOwner = locDetail.ownerId === myUserId;
|
isOwner = locDetail.ownerId === myUserId;
|
||||||
|
isAdmin = isOwner || !!locDetail.isAdmin;
|
||||||
isFollowing = !!locDetail.following;
|
isFollowing = !!locDetail.following;
|
||||||
|
|
||||||
renderPage();
|
renderPage();
|
||||||
|
|
||||||
|
if (isAdmin) {
|
||||||
|
const chatWithId = new URLSearchParams(location.search).get('chatWith');
|
||||||
|
const hash = location.hash.replace('#', '');
|
||||||
|
const initialTab = chatWithId ? 'posteingang' : (VALID_TABS.includes(hash) ? hash : 'grunddaten');
|
||||||
|
switchTab(initialTab);
|
||||||
|
renderAdminList();
|
||||||
|
loadInbox();
|
||||||
loadEvents();
|
loadEvents();
|
||||||
|
} else {
|
||||||
|
loadEvents();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPage() {
|
function renderPage() {
|
||||||
@@ -230,6 +332,7 @@ function renderPage() {
|
|||||||
<button class="btn" id="followBtn" style="font-size:0.85rem;${isFollowing ? 'background:var(--color-primary);color:#fff;' : 'background:var(--color-secondary);color:var(--color-text);'}" onclick="toggleFollow()">
|
<button class="btn" id="followBtn" style="font-size:0.85rem;${isFollowing ? 'background:var(--color-primary);color:#fff;' : 'background:var(--color-secondary);color:var(--color-text);'}" onclick="toggleFollow()">
|
||||||
${isFollowing ? '★ Abonniert' : '☆ Abonnieren'}
|
${isFollowing ? '★ Abonniert' : '☆ Abonnieren'}
|
||||||
</button>
|
</button>
|
||||||
|
${loc.virtualUserId && myUserId && !isAdmin ? `<button class="btn" style="font-size:0.85rem;" onclick="contactLocation('${loc.virtualUserId}')">✉ Kontaktieren</button>` : ''}
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
let hoursHtml = '';
|
let hoursHtml = '';
|
||||||
@@ -248,7 +351,7 @@ function renderPage() {
|
|||||||
|
|
||||||
const galleryHtml = buildGalleryHtml(loc.gallery || []);
|
const galleryHtml = buildGalleryHtml(loc.gallery || []);
|
||||||
|
|
||||||
document.getElementById('content').innerHTML = `
|
const locHeaderHtml = `
|
||||||
<div class="loc-header">
|
<div class="loc-header">
|
||||||
<div class="loc-avatar">${imgHtml}</div>
|
<div class="loc-avatar">${imgHtml}</div>
|
||||||
<div class="loc-meta">
|
<div class="loc-meta">
|
||||||
@@ -257,10 +360,9 @@ function renderPage() {
|
|||||||
${loc.description ? `<div class="loc-desc">${escHtml(loc.description)}</div>` : ''}
|
${loc.description ? `<div class="loc-desc">${escHtml(loc.description)}</div>` : ''}
|
||||||
${ownerActions}
|
${ownerActions}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>`;
|
||||||
|
|
||||||
${hoursHtml}
|
|
||||||
|
|
||||||
|
const gallerySection = `
|
||||||
<div class="section-title">
|
<div class="section-title">
|
||||||
Galerie
|
Galerie
|
||||||
${isOwner ? `<label class="btn" style="font-size:0.8rem;cursor:pointer;">
|
${isOwner ? `<label class="btn" style="font-size:0.8rem;cursor:pointer;">
|
||||||
@@ -268,14 +370,87 @@ function renderPage() {
|
|||||||
<input type="file" accept="image/*" style="display:none;" onchange="uploadGalleryImage(this)">
|
<input type="file" accept="image/*" style="display:none;" onchange="uploadGalleryImage(this)">
|
||||||
</label>` : ''}
|
</label>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="gallery-grid" id="galleryGrid">${galleryHtml}</div>
|
<div class="gallery-grid" id="galleryGrid">${galleryHtml}</div>`;
|
||||||
|
|
||||||
|
const eventsSection = `
|
||||||
<div class="section-title">
|
<div class="section-title">
|
||||||
Veranstaltungen
|
Veranstaltungen
|
||||||
${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="pastEventsSection" style="display:none;">
|
||||||
|
<div class="section-title" style="margin-top:1.5rem;">Vergangene Veranstaltungen</div>
|
||||||
|
<div class="event-list" id="pastEventList"></div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
if (isAdmin) {
|
||||||
|
document.getElementById('content').innerHTML = `
|
||||||
|
<div class="tab-bar">
|
||||||
|
<button class="tab-btn" data-tab="grunddaten" onclick="switchTab('grunddaten')">Grunddaten</button>
|
||||||
|
<button class="tab-btn" data-tab="admins" onclick="switchTab('admins')">Administrator*Innen</button>
|
||||||
|
<button class="tab-btn" data-tab="posteingang" onclick="switchTab('posteingang')">Posteingang</button>
|
||||||
|
<button class="tab-btn" data-tab="veranstaltungen" onclick="switchTab('veranstaltungen')">Veranstaltungen</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-panel" id="tab-grunddaten">
|
||||||
|
${locHeaderHtml}
|
||||||
|
${hoursHtml}
|
||||||
|
${gallerySection}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-panel" id="tab-admins">
|
||||||
|
<div id="adminList" style="display:flex;flex-direction:column;gap:0.5rem;margin-bottom:0.75rem;"></div>
|
||||||
|
<div style="display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap;">
|
||||||
|
<div style="flex:1;min-width:180px;">
|
||||||
|
<input type="text" id="adminSearchInput" placeholder="Mitglied suchen…" autocomplete="off"
|
||||||
|
oninput="onAdminSearch()" style="width:100%;box-sizing:border-box;">
|
||||||
|
</div>
|
||||||
|
<button class="btn" style="font-size:0.85rem;white-space:nowrap;" onclick="addAdminFromSearch()">+ Admin hinzufügen</button>
|
||||||
|
</div>
|
||||||
|
${isOwner ? `
|
||||||
|
<div style="margin-top:1rem;padding-top:1rem;border-top:1px solid var(--color-secondary);">
|
||||||
|
<div style="font-size:0.82rem;color:var(--color-muted);margin-bottom:0.5rem;">Inhaberrechte übertragen</div>
|
||||||
|
<div style="display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap;">
|
||||||
|
<div style="flex:1;min-width:180px;">
|
||||||
|
<input type="text" id="ownerSearchInput" placeholder="Mitglied suchen…" autocomplete="off"
|
||||||
|
oninput="onOwnerSearch()" style="width:100%;box-sizing:border-box;">
|
||||||
|
</div>
|
||||||
|
<button class="btn" style="font-size:0.85rem;white-space:nowrap;background:#c0392b;" onclick="transferOwner()">Inhaberwechsel</button>
|
||||||
|
</div>
|
||||||
|
</div>` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-panel" id="tab-posteingang">
|
||||||
|
<div id="inboxList" class="inbox-list"><p style="color:var(--color-muted);font-size:0.9rem;">Wird geladen…</p></div>
|
||||||
|
<div class="inbox-chat" id="inboxChat">
|
||||||
|
<div class="inbox-chat-header">
|
||||||
|
<button class="inbox-chat-back" onclick="closeInboxChat()" aria-label="Zurück">‹</button>
|
||||||
|
<span id="inboxChatName" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></span>
|
||||||
|
<button class="inbox-chat-close" onclick="closeInboxChat()" aria-label="Schließen">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="inbox-chat-messages" id="inboxChatMessages"></div>
|
||||||
|
<div class="inbox-lock-hint" id="inboxLockHint" style="display:none;"></div>
|
||||||
|
<div class="inbox-reply-trigger" id="inboxReplyTrigger" style="display:none;">
|
||||||
|
<button class="btn" onclick="startReplying()">✎ Antworten</button>
|
||||||
|
</div>
|
||||||
|
<div class="inbox-reply-area" id="inboxReplyArea" style="display:none;">
|
||||||
|
<input type="text" id="inboxReplyInput" placeholder="Antwort eingeben…" autocomplete="off"
|
||||||
|
onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendInboxReply();}">
|
||||||
|
<button class="btn inbox-reply-btn" onclick="sendInboxReply()">↑</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-panel" id="tab-veranstaltungen">
|
||||||
|
${eventsSection}
|
||||||
|
</div>`;
|
||||||
|
} else {
|
||||||
|
document.getElementById('content').innerHTML = `
|
||||||
|
${locHeaderHtml}
|
||||||
|
${hoursHtml}
|
||||||
|
${gallerySection}
|
||||||
|
${eventsSection}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildGalleryHtml(gallery) {
|
function buildGalleryHtml(gallery) {
|
||||||
@@ -298,7 +473,7 @@ async function uploadGalleryImage(input) {
|
|||||||
headers: {'Content-Type':'application/json'},
|
headers: {'Content-Type':'application/json'},
|
||||||
body: JSON.stringify({ imageData })
|
body: JSON.stringify({ imageData })
|
||||||
});
|
});
|
||||||
if (res.status === 422) { alert('Maximal 20 Galeriebilder erlaubt.'); return; }
|
if (res.status === 422) { showAlert('Maximal 20 Galeriebilder erlaubt.'); return; }
|
||||||
if (!res.ok) throw new Error();
|
if (!res.ok) throw new Error();
|
||||||
const img = await res.json();
|
const img = await res.json();
|
||||||
const grid = document.getElementById('galleryGrid');
|
const grid = document.getElementById('galleryGrid');
|
||||||
@@ -307,18 +482,34 @@ async function uploadGalleryImage(input) {
|
|||||||
<img src="data:image/jpeg;base64,${img.imageData}" alt="Galeriebild" onclick="openLightbox(this.src)">
|
<img src="data:image/jpeg;base64,${img.imageData}" alt="Galeriebild" onclick="openLightbox(this.src)">
|
||||||
<button class="gallery-del-btn" onclick="deleteGalleryImage('${img.imageId}')">✕</button>
|
<button class="gallery-del-btn" onclick="deleteGalleryImage('${img.imageId}')">✕</button>
|
||||||
</div>`);
|
</div>`);
|
||||||
} catch { alert('Fehler beim Hochladen.'); }
|
} catch { showAlert('Fehler beim Hochladen.'); }
|
||||||
input.value = '';
|
input.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteGalleryImage(imageId) {
|
async function deleteGalleryImage(imageId) {
|
||||||
if (!confirm('Bild löschen?')) return;
|
if (!confirm('Bild löschen?')) return;
|
||||||
const res = await fetch(`/locations/${locationId}/gallery/${imageId}`, { method: 'DELETE' });
|
const res = await fetch(`/locations/${locationId}/gallery/${imageId}`, { method: 'DELETE' });
|
||||||
if (!res.ok) { alert('Fehler beim Löschen.'); return; }
|
if (!res.ok) { showAlert('Fehler beim Löschen.'); return; }
|
||||||
await loadPage();
|
await loadPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Events ─────────────────────────────────────────────────────────────────────
|
// ── Events ─────────────────────────────────────────────────────────────────────
|
||||||
|
function buildEventCard(e, isPast) {
|
||||||
|
const imgHtml = e.imageData
|
||||||
|
? `<img src="data:image/jpeg;base64,${e.imageData}" alt="${escHtml(e.title)}">`
|
||||||
|
: '🗓';
|
||||||
|
const opacity = isPast ? 'opacity:0.6;' : '';
|
||||||
|
return `
|
||||||
|
<a class="event-card" href="/community/event-detail.html?id=${e.eventId}" style="${opacity}">
|
||||||
|
<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>`;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadEvents() {
|
async function loadEvents() {
|
||||||
const res = await fetch(`/locations/${locationId}/events`);
|
const res = await fetch(`/locations/${locationId}/events`);
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
@@ -326,37 +517,50 @@ async function loadEvents() {
|
|||||||
const list = document.getElementById('eventList');
|
const list = document.getElementById('eventList');
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
|
|
||||||
if (events.length === 0) {
|
const now = new Date();
|
||||||
list.innerHTML = '<p style="color:var(--color-muted);font-size:0.9rem;">Noch keine Veranstaltungen.</p>';
|
const future = events.filter(e => new Date(e.startAt) >= now);
|
||||||
return;
|
const past = events.filter(e => new Date(e.startAt) < now)
|
||||||
}
|
.slice(-5) // letzte 5
|
||||||
|
.reverse(); // neueste zuerst
|
||||||
|
|
||||||
list.innerHTML = events.map(e => {
|
list.innerHTML = future.length
|
||||||
const imgHtml = e.imageData
|
? future.map(e => buildEventCard(e, false)).join('')
|
||||||
? `<img src="data:image/jpeg;base64,${e.imageData}" alt="${escHtml(e.title)}">`
|
: '<p style="color:var(--color-muted);font-size:0.9rem;">Keine bevorstehenden Veranstaltungen.</p>';
|
||||||
: '🗓';
|
|
||||||
const deleteBtn = isOwner
|
const pastSection = document.getElementById('pastEventsSection');
|
||||||
? `<button class="btn" style="font-size:0.75rem;margin-top:0.3rem;background:var(--color-secondary);color:var(--color-text);padding:0.2rem 0.5rem;" onclick="event.preventDefault();deleteEvent('${e.eventId}')">Löschen</button>`
|
if (past.length && pastSection) {
|
||||||
: '';
|
document.getElementById('pastEventList').innerHTML = past.map(e => buildEventCard(e, true)).join('');
|
||||||
return `
|
pastSection.style.display = '';
|
||||||
<a class="event-card" href="/community/event-detail.html?id=${e.eventId}">
|
} else if (pastSection) {
|
||||||
<div class="event-card-img">${imgHtml}</div>
|
pastSection.style.display = 'none';
|
||||||
<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>
|
|
||||||
${deleteBtn}
|
|
||||||
</div>
|
|
||||||
</a>`;
|
|
||||||
}).join('');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Lightbox ───────────────────────────────────────────────────────────────────
|
// ── Lightbox ───────────────────────────────────────────────────────────────────
|
||||||
|
let lbSrcs = [], lbIdx = 0;
|
||||||
|
|
||||||
function openLightbox(src) {
|
function openLightbox(src) {
|
||||||
document.getElementById('lbImg').src = src;
|
lbSrcs = Array.from(document.querySelectorAll('#galleryGrid .gallery-img-wrap img')).map(i => i.src);
|
||||||
|
lbIdx = Math.max(0, lbSrcs.indexOf(src));
|
||||||
|
lbShow();
|
||||||
document.getElementById('lightbox').classList.add('open');
|
document.getElementById('lightbox').classList.add('open');
|
||||||
}
|
}
|
||||||
|
function lbShow() {
|
||||||
|
document.getElementById('lbImg').src = lbSrcs[lbIdx];
|
||||||
|
document.getElementById('lbPrev').disabled = lbIdx === 0;
|
||||||
|
document.getElementById('lbNext').disabled = lbIdx === lbSrcs.length - 1;
|
||||||
|
}
|
||||||
|
function lbNav(dir) {
|
||||||
|
lbIdx = Math.max(0, Math.min(lbSrcs.length - 1, lbIdx + dir));
|
||||||
|
lbShow();
|
||||||
|
}
|
||||||
function closeLightbox() { document.getElementById('lightbox').classList.remove('open'); }
|
function closeLightbox() { document.getElementById('lightbox').classList.remove('open'); }
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (!document.getElementById('lightbox').classList.contains('open')) return;
|
||||||
|
if (e.key === 'Escape') closeLightbox();
|
||||||
|
else if (e.key === 'ArrowLeft') lbNav(-1);
|
||||||
|
else if (e.key === 'ArrowRight') lbNav(1);
|
||||||
|
});
|
||||||
|
|
||||||
// ── Edit Modal ─────────────────────────────────────────────────────────────────
|
// ── Edit Modal ─────────────────────────────────────────────────────────────────
|
||||||
let _editLq = null, _editHq = null, _editLat = null, _editLon = null, _editStreet = null, _editCity = null, _editCityTimer = null;
|
let _editLq = null, _editHq = null, _editLat = null, _editLon = null, _editStreet = null, _editCity = null, _editCityTimer = null;
|
||||||
@@ -489,7 +693,7 @@ document.addEventListener('click', e => {
|
|||||||
|
|
||||||
async function submitEdit() {
|
async function submitEdit() {
|
||||||
const name = document.getElementById('editName').value.trim();
|
const name = document.getElementById('editName').value.trim();
|
||||||
if (!name) { alert('Name darf nicht leer sein.'); return; }
|
if (!name) { showAlert('Name darf nicht leer sein.'); return; }
|
||||||
const addrVal = document.getElementById('editCity').value.trim();
|
const addrVal = document.getElementById('editCity').value.trim();
|
||||||
if (addrVal && _editLat == null) {
|
if (addrVal && _editLat == null) {
|
||||||
document.getElementById('editLocMsg').textContent = 'Bitte eine Adresse aus der Vorschlagsliste auswählen oder per GPS ermitteln.';
|
document.getElementById('editLocMsg').textContent = 'Bitte eine Adresse aus der Vorschlagsliste auswählen oder per GPS ermitteln.';
|
||||||
@@ -530,7 +734,7 @@ async function submitEdit() {
|
|||||||
closeEditModal();
|
closeEditModal();
|
||||||
renderPage();
|
renderPage();
|
||||||
loadEvents();
|
loadEvents();
|
||||||
} catch { alert('Fehler beim Speichern.'); }
|
} catch { showAlert('Fehler beim Speichern.'); }
|
||||||
finally { btn.disabled = false; btn.textContent = 'Speichern'; }
|
finally { btn.disabled = false; btn.textContent = 'Speichern'; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -547,14 +751,14 @@ async function toggleFollow() {
|
|||||||
btn.style.background = isFollowing ? 'var(--color-primary)' : 'var(--color-secondary)';
|
btn.style.background = isFollowing ? 'var(--color-primary)' : 'var(--color-secondary)';
|
||||||
btn.style.color = isFollowing ? '#fff' : 'var(--color-text)';
|
btn.style.color = isFollowing ? '#fff' : 'var(--color-text)';
|
||||||
}
|
}
|
||||||
} catch (_) { alert('Fehler beim Aktualisieren des Abonnements.'); }
|
} catch (_) { showAlert('Fehler beim Aktualisieren des Abonnements.'); }
|
||||||
finally { if (btn) btn.disabled = false; }
|
finally { if (btn) btn.disabled = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteLocation() {
|
async function deleteLocation() {
|
||||||
if (!confirm('Location wirklich löschen? Alle Veranstaltungen und Galeriebilder werden ebenfalls gelöscht.')) return;
|
if (!confirm('Location wirklich löschen? Alle Veranstaltungen und Galeriebilder werden ebenfalls gelöscht.')) return;
|
||||||
const res = await fetch(`/locations/${locationId}`, { method: 'DELETE' });
|
const res = await fetch(`/locations/${locationId}`, { method: 'DELETE' });
|
||||||
if (!res.ok) { alert('Fehler beim Löschen.'); return; }
|
if (!res.ok) { showAlert('Fehler beim Löschen.'); return; }
|
||||||
window.location.href = '/community/locations.html';
|
window.location.href = '/community/locations.html';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -568,6 +772,9 @@ function openEventModal(evtId) {
|
|||||||
document.getElementById('eventTitle').value = '';
|
document.getElementById('eventTitle').value = '';
|
||||||
document.getElementById('eventDesc').value = '';
|
document.getElementById('eventDesc').value = '';
|
||||||
document.getElementById('eventStartAt').value = '';
|
document.getElementById('eventStartAt').value = '';
|
||||||
|
// Nur Termine in der Zukunft erlauben
|
||||||
|
const nowLocal = new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().slice(0, 16);
|
||||||
|
document.getElementById('eventStartAt').min = nowLocal;
|
||||||
document.getElementById('eventPicPreview').innerHTML = '🗓';
|
document.getElementById('eventPicPreview').innerHTML = '🗓';
|
||||||
document.getElementById('eventModal').classList.add('open');
|
document.getElementById('eventModal').classList.add('open');
|
||||||
}
|
}
|
||||||
@@ -582,8 +789,9 @@ async function onEventPicChange(input) {
|
|||||||
async function submitEvent() {
|
async function submitEvent() {
|
||||||
const title = document.getElementById('eventTitle').value.trim();
|
const title = document.getElementById('eventTitle').value.trim();
|
||||||
const startAt = document.getElementById('eventStartAt').value;
|
const startAt = document.getElementById('eventStartAt').value;
|
||||||
if (!title) { alert('Bitte gib einen Titel ein.'); return; }
|
if (!title) { showAlert('Bitte gib einen Titel ein.'); return; }
|
||||||
if (!startAt) { alert('Bitte wähle Datum und Uhrzeit.'); return; }
|
if (!startAt) { showAlert('Bitte wähle Datum und Uhrzeit.'); return; }
|
||||||
|
if (new Date(startAt) <= new Date()) { showAlert('Der Termin muss in der Zukunft liegen.'); return; }
|
||||||
|
|
||||||
const btn = document.getElementById('eventSubmitBtn');
|
const btn = document.getElementById('eventSubmitBtn');
|
||||||
btn.disabled = true; btn.textContent = 'Wird gespeichert…';
|
btn.disabled = true; btn.textContent = 'Wird gespeichert…';
|
||||||
@@ -610,15 +818,321 @@ async function submitEvent() {
|
|||||||
|
|
||||||
closeEventModal();
|
closeEventModal();
|
||||||
loadEvents();
|
loadEvents();
|
||||||
} catch { alert('Fehler beim Speichern.'); }
|
} catch { showAlert('Fehler beim Speichern.'); }
|
||||||
finally { btn.disabled = false; btn.textContent = 'Speichern'; }
|
finally { btn.disabled = false; btn.textContent = 'Speichern'; }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteEvent(eventId) {
|
function showAlert(message) {
|
||||||
if (!confirm('Veranstaltung löschen?')) return;
|
document.getElementById('alertMessage').textContent = message;
|
||||||
const res = await fetch(`/locations/${locationId}/events/${eventId}`, { method: 'DELETE' });
|
document.getElementById('alertModal').classList.add('open');
|
||||||
if (!res.ok) { alert('Fehler beim Löschen.'); return; }
|
}
|
||||||
loadEvents();
|
|
||||||
|
function openConfirm(title, message, onOk) {
|
||||||
|
document.getElementById('confirmTitle').textContent = title;
|
||||||
|
document.getElementById('confirmMessage').textContent = message;
|
||||||
|
const btn = document.getElementById('confirmOkBtn');
|
||||||
|
btn.onclick = () => { closeConfirm(); onOk(); };
|
||||||
|
document.getElementById('confirmModal').classList.add('open');
|
||||||
|
}
|
||||||
|
function closeConfirm() { document.getElementById('confirmModal').classList.remove('open'); }
|
||||||
|
|
||||||
|
// ── Admin-Verwaltung ──────────────────────────────────────────────────────────
|
||||||
|
let _adminSearchSelected = null;
|
||||||
|
let _ownerSearchSelected = null;
|
||||||
|
let _adminSearchTimer = null;
|
||||||
|
let _ownerSearchTimer = null;
|
||||||
|
|
||||||
|
function renderAdminList() {
|
||||||
|
const list = document.getElementById('adminList');
|
||||||
|
if (!list || !locDetail.admins) return;
|
||||||
|
list.innerHTML = locDetail.admins.map(a => {
|
||||||
|
const pic = a.profilePicture
|
||||||
|
? `<img src="data:image/jpeg;base64,${a.profilePicture}" alt="" style="width:100%;height:100%;object-fit:cover;border-radius:50%;">`
|
||||||
|
: '◉';
|
||||||
|
const badge = a.isOwner
|
||||||
|
? `<span style="font-size:0.7rem;background:var(--color-primary);color:#fff;border-radius:4px;padding:0.1rem 0.4rem;margin-left:0.4rem;">Inhaber</span>`
|
||||||
|
: '';
|
||||||
|
const removeBtn = isAdmin && !a.isOwner
|
||||||
|
? `<button onclick="removeAdmin('${a.userId}')" style="margin-left:auto;background:none;border:none;color:var(--color-muted);cursor:pointer;font-size:1rem;padding:0.2rem 0.4rem;" title="Entfernen">✕</button>`
|
||||||
|
: '';
|
||||||
|
return `<div style="display:flex;align-items:center;gap:0.6rem;padding:0.4rem 0;">
|
||||||
|
<div style="width:32px;height:32px;border-radius:50%;background:var(--color-secondary);display:flex;align-items:center;justify-content:center;font-size:1rem;overflow:hidden;flex-shrink:0;">${pic}</div>
|
||||||
|
<span style="font-size:0.9rem;">${escHtml(a.name)}</span>${badge}${removeBtn}
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeAdmin(userId) {
|
||||||
|
const res = await fetch(`/locations/${locationId}/admins/${userId}`, { method: 'DELETE' });
|
||||||
|
if (!res.ok) { showAlert('Fehler beim Entfernen.'); return; }
|
||||||
|
locDetail.admins = locDetail.admins.filter(a => a.userId !== userId);
|
||||||
|
renderAdminList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAdminSearch() {
|
||||||
|
const q = document.getElementById('adminSearchInput').value.trim();
|
||||||
|
_adminSearchSelected = null;
|
||||||
|
clearTimeout(_adminSearchTimer);
|
||||||
|
if (q.length < 2) { document.getElementById('adminSearchList').style.display = 'none'; return; }
|
||||||
|
_adminSearchTimer = setTimeout(() => fetchUserSuggestions(q, 'adminSearchList', sel => { _adminSearchSelected = sel; }), 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onOwnerSearch() {
|
||||||
|
const q = document.getElementById('ownerSearchInput').value.trim();
|
||||||
|
_ownerSearchSelected = null;
|
||||||
|
clearTimeout(_ownerSearchTimer);
|
||||||
|
if (q.length < 2) { document.getElementById('ownerSearchList').style.display = 'none'; return; }
|
||||||
|
_ownerSearchTimer = setTimeout(() => fetchUserSuggestions(q, 'ownerSearchList', sel => { _ownerSearchSelected = sel; }), 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
const _userCache = {};
|
||||||
|
|
||||||
|
async function fetchUserSuggestions(q, listId, onSelect) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/social/users/search?q=${encodeURIComponent(q)}`);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const users = await res.json();
|
||||||
|
const ul = document.getElementById(listId);
|
||||||
|
if (!users.length) { ul.style.display = 'none'; return; }
|
||||||
|
users.forEach(u => { _userCache[u.userId] = u; });
|
||||||
|
ul.innerHTML = users.map(u => `
|
||||||
|
<li style="padding:0.45rem 0.75rem;cursor:pointer;font-size:0.9rem;border-bottom:1px solid var(--color-secondary);"
|
||||||
|
onmousedown="event.preventDefault();selectUserSuggestion('${listId}','${u.userId}')">
|
||||||
|
${escHtml(u.name)}
|
||||||
|
</li>`).join('');
|
||||||
|
ul.__selectFn = onSelect;
|
||||||
|
// Dropdown unter dem zugehörigen Input positionieren
|
||||||
|
const inputId = listId === 'adminSearchList' ? 'adminSearchInput' : 'ownerSearchInput';
|
||||||
|
const rect = document.getElementById(inputId).getBoundingClientRect();
|
||||||
|
ul.style.top = (rect.bottom + 2) + 'px';
|
||||||
|
ul.style.left = rect.left + 'px';
|
||||||
|
ul.style.width = rect.width + 'px';
|
||||||
|
ul.style.display = '';
|
||||||
|
} catch(_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectUserSuggestion(listId, userId) {
|
||||||
|
const ul = document.getElementById(listId);
|
||||||
|
const inputId = listId === 'adminSearchList' ? 'adminSearchInput' : 'ownerSearchInput';
|
||||||
|
const user = _userCache[userId];
|
||||||
|
if (!user) return;
|
||||||
|
document.getElementById(inputId).value = user.name;
|
||||||
|
ul.style.display = 'none';
|
||||||
|
if (ul.__selectFn) ul.__selectFn(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
['adminSearchList','ownerSearchList'].forEach(id => {
|
||||||
|
const ul = document.getElementById(id);
|
||||||
|
if (ul && !e.target.closest('#' + id) && e.target.id !== (id === 'adminSearchList' ? 'adminSearchInput' : 'ownerSearchInput'))
|
||||||
|
ul.style.display = 'none';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function addAdminFromSearch() {
|
||||||
|
if (!_adminSearchSelected) { showAlert('Bitte wähle ein Mitglied aus der Liste.'); return; }
|
||||||
|
const res = await fetch(`/locations/${locationId}/admins`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({ userId: _adminSearchSelected.userId })
|
||||||
|
});
|
||||||
|
if (res.status === 409) { showAlert('Diese Person ist bereits Admin.'); return; }
|
||||||
|
if (!res.ok) { showAlert('Fehler beim Hinzufügen.'); return; }
|
||||||
|
const added = await res.json();
|
||||||
|
locDetail.admins = [...(locDetail.admins || []), added];
|
||||||
|
renderAdminList();
|
||||||
|
document.getElementById('adminSearchInput').value = '';
|
||||||
|
_adminSearchSelected = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function transferOwner() {
|
||||||
|
if (!_ownerSearchSelected) { showAlert('Bitte wähle ein Mitglied aus der Liste.'); return; }
|
||||||
|
openConfirm(
|
||||||
|
'Inhaberwechsel',
|
||||||
|
`Inhaberrechte wirklich an „${_ownerSearchSelected.name}" übertragen? Diese Aktion kann nicht rückgängig gemacht werden.`,
|
||||||
|
async () => {
|
||||||
|
const res = await fetch(`/locations/${locationId}/admins/transfer-owner`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({ userId: _ownerSearchSelected.userId })
|
||||||
|
});
|
||||||
|
if (!res.ok) { showAlert('Fehler beim Inhaberwechsel.'); return; }
|
||||||
|
// Seite neu laden – aktueller User ist jetzt kein Inhaber mehr
|
||||||
|
await loadPage();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Kontaktieren ──────────────────────────────────────────────────────────────
|
||||||
|
function contactLocation(virtualUserId) {
|
||||||
|
window.location.href = '/community/nachrichten.html?partnerId=' + virtualUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Posteingang (Admin) ───────────────────────────────────────────────────────
|
||||||
|
let _inboxPartnerId = null;
|
||||||
|
|
||||||
|
async function loadInbox() {
|
||||||
|
const el = document.getElementById('inboxList');
|
||||||
|
if (!el) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/locations/${locationId}/inbox`);
|
||||||
|
if (!res.ok) { el.innerHTML = '<p style="color:var(--color-muted);font-size:0.9rem;">Nicht verfügbar.</p>'; return; }
|
||||||
|
const items = await res.json();
|
||||||
|
if (items.length === 0) {
|
||||||
|
el.innerHTML = '<p style="color:var(--color-muted);font-size:0.9rem;">Noch keine Nachrichten.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = items.map(item => {
|
||||||
|
const av = item.senderPicture
|
||||||
|
? `<img src="data:image/png;base64,${item.senderPicture}" alt="">`
|
||||||
|
: '◉';
|
||||||
|
const unreadHtml = item.unreadCount > 0
|
||||||
|
? `<span class="inbox-unread">${item.unreadCount}</span>` : '';
|
||||||
|
return `<div class="inbox-item" onclick="openInboxChat('${item.senderId}','${escHtml(item.senderName)}')">
|
||||||
|
<div class="inbox-avatar">${av}</div>
|
||||||
|
<div class="inbox-info">
|
||||||
|
<div class="inbox-name">${escHtml(item.senderName)}</div>
|
||||||
|
<div class="inbox-preview">${escHtml(item.lastMessage)}</div>
|
||||||
|
</div>
|
||||||
|
${unreadHtml}
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// URL-Parameter: Chat direkt öffnen (z.B. nach Klick auf Benachrichtigung)
|
||||||
|
const chatWithId = new URLSearchParams(location.search).get('chatWith');
|
||||||
|
if (chatWithId && !_inboxPartnerId) {
|
||||||
|
const match = items.find(i => i.senderId === chatWithId);
|
||||||
|
if (match) openInboxChat(match.senderId, match.senderName);
|
||||||
|
}
|
||||||
|
} catch { el.innerHTML = '<p style="color:var(--color-muted);font-size:0.9rem;">Fehler beim Laden.</p>'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openInboxChat(partnerId, partnerName) {
|
||||||
|
_inboxPartnerId = partnerId;
|
||||||
|
document.getElementById('inboxChatName').textContent = partnerName;
|
||||||
|
document.getElementById('inboxChatMessages').innerHTML = '<p style="color:var(--color-muted);font-size:0.85rem;text-align:center;">Wird geladen…</p>';
|
||||||
|
document.getElementById('inboxChat').classList.add('open');
|
||||||
|
document.getElementById('inboxReplyInput').value = '';
|
||||||
|
// Eingabebereich zurücksetzen
|
||||||
|
document.getElementById('inboxReplyTrigger').style.display = 'none';
|
||||||
|
document.getElementById('inboxReplyArea').style.display = 'none';
|
||||||
|
document.getElementById('inboxLockHint').style.display = 'none';
|
||||||
|
|
||||||
|
const virtualId = locDetail.virtualUserId;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/locations/${locationId}/inbox/${partnerId}`);
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
const data = await res.json();
|
||||||
|
const messages = data.messages || [];
|
||||||
|
|
||||||
|
const container = document.getElementById('inboxChatMessages');
|
||||||
|
container.innerHTML = '';
|
||||||
|
if (messages.length === 0) {
|
||||||
|
container.innerHTML = '<p style="color:var(--color-muted);font-size:0.85rem;text-align:center;">Noch keine Nachrichten.</p>';
|
||||||
|
} else {
|
||||||
|
messages.forEach(m => {
|
||||||
|
const isLocationSender = m.senderId === virtualId;
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'inbox-bubble-wrap ' + (isLocationSender ? 'me' : 'them');
|
||||||
|
const time = new Date(m.sentAt).toLocaleTimeString('de', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
wrap.innerHTML = `<div class="inbox-bubble">${escHtml(m.text)}</div><div class="inbox-bubble-time">${time}</div>`;
|
||||||
|
container.appendChild(wrap);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
requestAnimationFrame(() => { container.scrollTop = container.scrollHeight; });
|
||||||
|
|
||||||
|
// Lock-Status anzeigen
|
||||||
|
applyLockUi(data);
|
||||||
|
loadInbox();
|
||||||
|
} catch {
|
||||||
|
document.getElementById('inboxChatMessages').innerHTML = '<p style="color:var(--color-muted);font-size:0.85rem;text-align:center;">Fehler beim Laden.</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLockUi(data) {
|
||||||
|
const trigger = document.getElementById('inboxReplyTrigger');
|
||||||
|
const area = document.getElementById('inboxReplyArea');
|
||||||
|
const lockHint = document.getElementById('inboxLockHint');
|
||||||
|
|
||||||
|
if (data.lockedByMe) {
|
||||||
|
// Ich habe die Sperre → Eingabefeld direkt zeigen
|
||||||
|
trigger.style.display = 'none';
|
||||||
|
area.style.display = '';
|
||||||
|
lockHint.style.display = 'none';
|
||||||
|
document.getElementById('inboxReplyInput').focus();
|
||||||
|
} else if (data.canReply) {
|
||||||
|
// Frei → "Antworten"-Button zeigen
|
||||||
|
trigger.style.display = '';
|
||||||
|
area.style.display = 'none';
|
||||||
|
lockHint.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
// Gesperrt durch anderen Admin
|
||||||
|
trigger.style.display = 'none';
|
||||||
|
area.style.display = 'none';
|
||||||
|
lockHint.style.display = '';
|
||||||
|
lockHint.textContent = `Wird gerade von ${data.lockedByName || 'einem anderen Admin'} beantwortet.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startReplying() {
|
||||||
|
if (!_inboxPartnerId) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/locations/${locationId}/inbox/${_inboxPartnerId}/lock`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
if (res.status === 409) {
|
||||||
|
const body = await res.json();
|
||||||
|
const name = body.lockedByName || 'einem anderen Admin';
|
||||||
|
document.getElementById('inboxReplyTrigger').style.display = 'none';
|
||||||
|
document.getElementById('inboxLockHint').style.display = '';
|
||||||
|
document.getElementById('inboxLockHint').textContent =
|
||||||
|
`Wird gerade von ${name} beantwortet.`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res.ok) { showAlert('Sperre konnte nicht erworben werden.'); return; }
|
||||||
|
document.getElementById('inboxReplyTrigger').style.display = 'none';
|
||||||
|
document.getElementById('inboxReplyArea').style.display = '';
|
||||||
|
document.getElementById('inboxReplyInput').focus();
|
||||||
|
} catch { showAlert('Fehler beim Sperren.'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeInboxChat() {
|
||||||
|
_inboxPartnerId = null;
|
||||||
|
document.getElementById('inboxChat').classList.remove('open');
|
||||||
|
document.getElementById('inboxReplyTrigger').style.display = 'none';
|
||||||
|
document.getElementById('inboxReplyArea').style.display = 'none';
|
||||||
|
document.getElementById('inboxLockHint').style.display = 'none';
|
||||||
|
loadInbox();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendInboxReply() {
|
||||||
|
if (!_inboxPartnerId) return;
|
||||||
|
const input = document.getElementById('inboxReplyInput');
|
||||||
|
const text = input.value.trim();
|
||||||
|
if (!text) return;
|
||||||
|
input.value = '';
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/locations/${locationId}/inbox/${_inboxPartnerId}/reply`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ text })
|
||||||
|
});
|
||||||
|
if (res.status === 409) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
const name = body.lockedByName || 'einem anderen Admin';
|
||||||
|
input.value = text;
|
||||||
|
// Eingabe sperren, Hinweis zeigen
|
||||||
|
document.getElementById('inboxReplyArea').style.display = 'none';
|
||||||
|
document.getElementById('inboxLockHint').style.display = '';
|
||||||
|
document.getElementById('inboxLockHint').textContent =
|
||||||
|
`Wird gerade von ${name} beantwortet. Deine Nachricht wurde nicht gesendet.`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res.ok) { showAlert('Fehler beim Senden.'); input.value = text; return; }
|
||||||
|
// Konversation neu laden (Lock ist nach Senden bei mir)
|
||||||
|
const partnerName = document.getElementById('inboxChatName').textContent;
|
||||||
|
await openInboxChat(_inboxPartnerId, partnerName);
|
||||||
|
} catch { showAlert('Fehler beim Senden.'); input.value = text; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Init ──────────────────────────────────────────────────────────────────────
|
// ── Init ──────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -121,6 +121,8 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span class="filter-badge" id="filterBadge" style="display:none;position:absolute;top:-3px;right:-3px;"></span>
|
<span class="filter-badge" id="filterBadge" style="display:none;position:absolute;top:-3px;right:-3px;"></span>
|
||||||
</button>
|
</button>
|
||||||
|
<button id="createLocBtn" class="btn" onclick="openCreateModal()"
|
||||||
|
style="display:none;padding:0.35rem 0.85rem;font-size:0.85rem;">+ Location anlegen</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -134,9 +136,6 @@
|
|||||||
|
|
||||||
<!-- ── Meine Locations ──────────────────────────────────────────── -->
|
<!-- ── Meine Locations ──────────────────────────────────────────── -->
|
||||||
<div id="paneMine" class="tab-panel">
|
<div id="paneMine" class="tab-panel">
|
||||||
<div style="display:flex; justify-content:flex-end; margin-bottom:1rem;">
|
|
||||||
<button class="btn" onclick="openCreateModal()">+ Location anlegen</button>
|
|
||||||
</div>
|
|
||||||
<div class="loc-grid" id="mineGrid"></div>
|
<div class="loc-grid" id="mineGrid"></div>
|
||||||
<p class="empty-hint" id="mineEmpty" style="display:none;">Du hast noch keine Locations angelegt.</p>
|
<p class="empty-hint" id="mineEmpty" style="display:none;">Du hast noch keine Locations angelegt.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -228,6 +227,8 @@ function switchTab(name, btn) {
|
|||||||
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
||||||
btn.classList.add('active');
|
btn.classList.add('active');
|
||||||
document.getElementById('pane' + name.charAt(0).toUpperCase() + name.slice(1)).classList.add('active');
|
document.getElementById('pane' + name.charAt(0).toUpperCase() + name.slice(1)).classList.add('active');
|
||||||
|
document.getElementById('filterOpenBtn').style.display = name === 'search' ? '' : 'none';
|
||||||
|
document.getElementById('createLocBtn').style.display = name === 'mine' ? '' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Filter-Drawer ─────────────────────────────────────────────────────────────
|
// ── Filter-Drawer ─────────────────────────────────────────────────────────────
|
||||||
@@ -378,8 +379,8 @@ async function loadNextBatch() {
|
|||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.className = 'loc-card';
|
a.className = 'loc-card';
|
||||||
a.href = `/community/location-detail.html?id=${p.locationId}`;
|
a.href = `/community/location-detail.html?id=${p.locationId}`;
|
||||||
const imgHtml = p.profilePictureLq
|
const imgHtml = p.profilePictureHq
|
||||||
? `<img src="data:image/jpeg;base64,${p.profilePictureLq}" alt="${escHtml(p.name)}">`
|
? `<img src="data:image/jpeg;base64,${p.profilePictureHq}" alt="${escHtml(p.name)}">`
|
||||||
: '<span>📍</span>';
|
: '<span>📍</span>';
|
||||||
a.innerHTML = `
|
a.innerHTML = `
|
||||||
<div class="loc-card-img">${imgHtml}</div>
|
<div class="loc-card-img">${imgHtml}</div>
|
||||||
@@ -421,8 +422,8 @@ async function loadMine() {
|
|||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.className = 'loc-card';
|
a.className = 'loc-card';
|
||||||
a.href = `/community/location-detail.html?id=${p.locationId}`;
|
a.href = `/community/location-detail.html?id=${p.locationId}`;
|
||||||
const imgHtml = p.profilePictureLq
|
const imgHtml = p.profilePictureHq
|
||||||
? `<img src="data:image/jpeg;base64,${p.profilePictureLq}" alt="${escHtml(p.name)}">`
|
? `<img src="data:image/jpeg;base64,${p.profilePictureHq}" alt="${escHtml(p.name)}">`
|
||||||
: '<span>📍</span>';
|
: '<span>📍</span>';
|
||||||
a.innerHTML = `
|
a.innerHTML = `
|
||||||
<div class="loc-card-img">${imgHtml}</div>
|
<div class="loc-card-img">${imgHtml}</div>
|
||||||
|
|||||||
@@ -341,7 +341,8 @@
|
|||||||
if (!user) return;
|
if (!user) return;
|
||||||
myId = user.userId;
|
myId = user.userId;
|
||||||
loadConversations();
|
loadConversations();
|
||||||
const urlPartnerId = new URLSearchParams(window.location.search).get('userId');
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const urlPartnerId = params.get('partnerId') || params.get('userId');
|
||||||
if (urlPartnerId) openThread(urlPartnerId);
|
if (urlPartnerId) openThread(urlPartnerId);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
@@ -363,31 +364,36 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
convs.forEach(c => {
|
convs.forEach(c => {
|
||||||
|
const isLocation = c.partner.friendStatus === 'LOCATION';
|
||||||
const av = c.partner.profilePicture
|
const av = c.partner.profilePicture
|
||||||
? `<img src="data:image/png;base64,${c.partner.profilePicture}" alt="" style="cursor:zoom-in;" onclick="event.stopPropagation();openLightbox(this.src)">`
|
? `<img src="data:image/png;base64,${c.partner.profilePicture}" alt="" style="cursor:zoom-in;" onclick="event.stopPropagation();openLightbox(this.src)">`
|
||||||
: '◉';
|
: (isLocation ? '📍' : '◉');
|
||||||
const unreadHtml = c.unreadCount > 0
|
const unreadHtml = c.unreadCount > 0
|
||||||
? `<span class="conv-unread">${c.unreadCount}</span>`
|
? `<span class="conv-unread">${c.unreadCount}</span>`
|
||||||
: '';
|
: '';
|
||||||
const preview = c.lastMessage
|
const preview = c.lastMessage
|
||||||
? (c.lastMessage.text.startsWith('data:image/') ? '📷 Bild' : esc(c.lastMessage.text.substring(0, 40)))
|
? (c.lastMessage.text.startsWith('data:image/') ? '📷 Bild' : esc(c.lastMessage.text.substring(0, 40)))
|
||||||
: '';
|
: '';
|
||||||
|
const locationBadge = isLocation
|
||||||
|
? `<span style="font-size:0.6rem;background:var(--color-secondary);color:var(--color-muted);border-radius:4px;padding:0.1rem 0.3rem;margin-left:0.3rem;">Location</span>`
|
||||||
|
: '';
|
||||||
const li = document.createElement('li');
|
const li = document.createElement('li');
|
||||||
li.className = 'conv-item' + (c.partner.userId === activePartnerId ? ' active' : '');
|
li.className = 'conv-item' + (c.partner.userId === activePartnerId ? ' active' : '');
|
||||||
li.dataset.partnerId = c.partner.userId;
|
li.dataset.partnerId = c.partner.userId;
|
||||||
|
li.dataset.isLocation = isLocation ? '1' : '';
|
||||||
li.innerHTML = `
|
li.innerHTML = `
|
||||||
<div class="conv-avatar">${av}</div>
|
<div class="conv-avatar">${av}</div>
|
||||||
<div class="conv-info">
|
<div class="conv-info">
|
||||||
<div class="conv-name">${esc(c.partner.name)}</div>
|
<div class="conv-name">${esc(c.partner.name)}${locationBadge}</div>
|
||||||
<div class="conv-preview">${preview}</div>
|
<div class="conv-preview">${preview}</div>
|
||||||
</div>
|
</div>
|
||||||
${unreadHtml}`;
|
${unreadHtml}`;
|
||||||
li.addEventListener('click', () => openThread(c.partner.userId, c.partner.name, c.partner.profilePicture));
|
li.addEventListener('click', () => openThread(c.partner.userId, c.partner.name, c.partner.profilePicture, isLocation));
|
||||||
list.appendChild(li);
|
list.appendChild(li);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openThread(partnerId, partnerName, partnerPic) {
|
async function openThread(partnerId, partnerName, partnerPic, isLocation) {
|
||||||
activePartnerId = partnerId;
|
activePartnerId = partnerId;
|
||||||
oldestSentAt = null;
|
oldestSentAt = null;
|
||||||
newestSentAt = null;
|
newestSentAt = null;
|
||||||
@@ -398,17 +404,51 @@
|
|||||||
li.classList.toggle('active', li.dataset.partnerId === partnerId);
|
li.classList.toggle('active', li.dataset.partnerId === partnerId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// isLocation ggf. aus DOM ermitteln oder per API auflösen
|
||||||
|
if (isLocation === undefined) {
|
||||||
|
const convItem = document.querySelector(`.conv-item[data-partner-id="${partnerId}"]`);
|
||||||
|
if (convItem) {
|
||||||
|
isLocation = convItem.dataset.isLocation === '1';
|
||||||
|
} else {
|
||||||
|
// Noch kein Conv-Item (neue/unbekannte Konversation) → per API prüfen
|
||||||
|
try {
|
||||||
|
const locRes = await fetch('/locations/virtual/' + partnerId);
|
||||||
|
if (locRes.ok) {
|
||||||
|
isLocation = true;
|
||||||
|
const loc = await locRes.json();
|
||||||
|
if (!partnerName) partnerName = loc.name;
|
||||||
|
if (!partnerPic && loc.profilePictureLq) partnerPic = loc.profilePictureLq;
|
||||||
|
} else {
|
||||||
|
isLocation = false;
|
||||||
|
}
|
||||||
|
} catch { isLocation = false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!partnerName) {
|
if (!partnerName) {
|
||||||
const convItem = document.querySelector(`.conv-item[data-partner-id="${partnerId}"]`);
|
const convItem = document.querySelector(`.conv-item[data-partner-id="${partnerId}"]`);
|
||||||
partnerName = convItem ? convItem.querySelector('.conv-name').textContent : '…';
|
// .conv-name enthält ggf. das Location-Badge – nur Textinhalt nehmen
|
||||||
|
partnerName = convItem ? convItem.querySelector('.conv-name').firstChild.textContent.trim() : '…';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const locationBadge = isLocation
|
||||||
|
? ` <span style="font-size:0.65rem;background:var(--color-secondary);color:var(--color-muted);border-radius:4px;padding:0.1rem 0.35rem;vertical-align:middle;">Location</span>`
|
||||||
|
: '';
|
||||||
|
if (isLocation) {
|
||||||
|
document.getElementById('threadPartnerName').innerHTML =
|
||||||
|
`${esc(partnerName)}${locationBadge}`;
|
||||||
|
} else {
|
||||||
document.getElementById('threadPartnerName').innerHTML =
|
document.getElementById('threadPartnerName').innerHTML =
|
||||||
`<a href="/community/benutzer.html?userId=${partnerId}" style="color:inherit;text-decoration:none;">${esc(partnerName)}</a>`;
|
`<a href="/community/benutzer.html?userId=${partnerId}" style="color:inherit;text-decoration:none;">${esc(partnerName)}</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
const avatarEl = document.getElementById('threadPartnerAvatar');
|
const avatarEl = document.getElementById('threadPartnerAvatar');
|
||||||
if (partnerPic) {
|
if (partnerPic) {
|
||||||
avatarEl.innerHTML = `<img src="data:image/png;base64,${partnerPic}" alt="" style="cursor:zoom-in;" onclick="openLightbox(this.src)">`;
|
avatarEl.innerHTML = `<img src="data:image/png;base64,${partnerPic}" alt="" style="cursor:zoom-in;" onclick="openLightbox(this.src)">`;
|
||||||
avatarEl.style.display = '';
|
avatarEl.style.display = '';
|
||||||
|
} else if (isLocation) {
|
||||||
|
avatarEl.innerHTML = '📍';
|
||||||
|
avatarEl.style.display = '';
|
||||||
} else {
|
} else {
|
||||||
avatarEl.style.display = 'none';
|
avatarEl.style.display = 'none';
|
||||||
}
|
}
|
||||||
@@ -548,7 +588,12 @@
|
|||||||
input.value = text;
|
input.value = text;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// War die Konversation leer, neu laden; sonst nur neue Nachrichten pollen
|
||||||
|
if (newestSentAt) {
|
||||||
await pollNewMessages();
|
await pollNewMessages();
|
||||||
|
} else {
|
||||||
|
await loadInitialThread();
|
||||||
|
}
|
||||||
} catch (e) { console.error(e); }
|
} catch (e) { console.error(e); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -516,6 +516,17 @@ body.app {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-cat-label {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
color: var(--color-muted);
|
||||||
|
padding: 0.9rem 1.1rem 0.25rem;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-group-toggle {
|
.sidebar-group-toggle {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|||||||
@@ -44,23 +44,41 @@
|
|||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
// ── Community-Links (immer sichtbar, oberhalb der Spiele) ──
|
// ── Hilfsfunktion: einzelner Nav-Link ──
|
||||||
const socialLinks = [
|
function navLink({ href, icon, label, badgeId }) {
|
||||||
{ href: '/userhome.html', icon: I('HOME') || '⌂', label: 'Home', badgeId: null },
|
|
||||||
{ href: '/community/feed.html', icon: I('FEED'), label: 'Feed', badgeId: null },
|
|
||||||
{ href: '/community/freunde.html', icon: I('FRIENDS'), label: 'Freunde', badgeId: 'socialFriendsBadge'},
|
|
||||||
{ href: '/community/nachrichten.html', icon: I('MESSAGES'), label: 'Nachrichten', badgeId: null },
|
|
||||||
{ href: '/community/benachrichtigungen.html', icon: I('NOTIFICATIONS'), label: 'Benachrichtigungen', badgeId: null },
|
|
||||||
{ href: '/community/gruppen.html', icon: I('GROUPS'), label: 'Gruppen', badgeId: 'socialGruppenBadge'},
|
|
||||||
{ href: '/community/locations.html', icon: I('LOCATION') || '📍', label: 'Locations', badgeId: null },
|
|
||||||
{ href: '/community/events.html', icon: I('EVENT') || '🗓', label: 'Veranstaltungen', badgeId: null },
|
|
||||||
{ href: '/games/common/einladungen.html', icon: I('INVITATIONS'), label: 'Einladungen', badgeId: null },
|
|
||||||
];
|
|
||||||
const socialNav = socialLinks.map(({ href, icon, label, badgeId }) => {
|
|
||||||
const cls = path === href ? ' class="active"' : '';
|
const cls = path === href ? ' class="active"' : '';
|
||||||
const badge = badgeId ? `<span class="social-badge" id="${badgeId}" style="display:none;"></span>` : '';
|
const badge = badgeId ? `<span class="social-badge" id="${badgeId}" style="display:none;"></span>` : '';
|
||||||
return `<li><a href="${href}"${cls}><span class="icon">${icon}</span> ${label}${badge}</a></li>`;
|
return `<li><a href="${href}"${cls}><span class="icon">${icon}</span> ${label}${badge}</a></li>`;
|
||||||
}).join('');
|
}
|
||||||
|
const sep = `<li><hr style="border:none;border-top:1px solid var(--color-secondary);margin:0.4rem 1rem;"></li>`;
|
||||||
|
const catLabel = label => `<li class="sidebar-cat-label">${label}</li>`;
|
||||||
|
|
||||||
|
// Home
|
||||||
|
const homeNav = navLink({ href: '/userhome.html', icon: I('HOME') || '⌂', label: 'Home' });
|
||||||
|
|
||||||
|
// Kommunikation
|
||||||
|
const commLinks = [
|
||||||
|
{ href: '/community/nachrichten.html', icon: I('MESSAGES'), label: 'Nachrichten' },
|
||||||
|
{ href: '/community/benachrichtigungen.html', icon: I('NOTIFICATIONS'), label: 'Benachrichtigungen' },
|
||||||
|
{ href: '/games/common/einladungen.html', icon: I('INVITATIONS'), label: 'Einladungen' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Social
|
||||||
|
const socialLinks = [
|
||||||
|
{ href: '/community/feed.html', icon: I('FEED'), label: 'Feed' },
|
||||||
|
{ href: '/community/freunde.html', icon: I('FRIENDS'), label: 'Freunde', badgeId: 'socialFriendsBadge' },
|
||||||
|
{ href: '/community/gruppen.html', icon: I('GROUPS'), label: 'Gruppen', badgeId: 'socialGruppenBadge' },
|
||||||
|
{ href: '/community/locations.html', icon: I('LOCATION') || '📍', label: 'Locations' },
|
||||||
|
{ href: '/community/events.html', icon: I('EVENT') || '🗓', label: 'Veranstaltungen' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const socialNav = [
|
||||||
|
homeNav,
|
||||||
|
sep,
|
||||||
|
...commLinks.map(navLink),
|
||||||
|
sep,
|
||||||
|
...socialLinks.map(navLink),
|
||||||
|
].join('');
|
||||||
|
|
||||||
const datingActive = path === '/dating.html';
|
const datingActive = path === '/dating.html';
|
||||||
const datingCls = datingActive ? ' class="active"' : '';
|
const datingCls = datingActive ? ' class="active"' : '';
|
||||||
@@ -106,7 +124,7 @@
|
|||||||
<div class="sidebar-scroll-area">
|
<div class="sidebar-scroll-area">
|
||||||
<ul>
|
<ul>
|
||||||
${socialNav}
|
${socialNav}
|
||||||
<li><hr style="border:none;border-top:1px solid var(--color-secondary);margin:0.4rem 1rem;"></li>
|
${sep}
|
||||||
${datingItem}
|
${datingItem}
|
||||||
<li><hr style="border:none;border-top:1px solid var(--color-secondary);margin:0.4rem 1rem;"></li>
|
<li><hr style="border:none;border-top:1px solid var(--color-secondary);margin:0.4rem 1rem;"></li>
|
||||||
${nav}
|
${nav}
|
||||||
|
|||||||
@@ -107,6 +107,29 @@
|
|||||||
.match-badge {
|
.match-badge {
|
||||||
font-size: 0.65rem; color: var(--color-primary); text-align: center; font-weight: 600;
|
font-size: 0.65rem; color: var(--color-primary); text-align: center; font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Location-Events ── */
|
||||||
|
.loc-event-list { display: flex; flex-direction: column; gap: 0.6rem; }
|
||||||
|
.loc-event-card {
|
||||||
|
display: flex; gap: 0.75rem; align-items: center;
|
||||||
|
background: var(--color-secondary); border: 1px solid var(--color-secondary);
|
||||||
|
border-radius: 10px; padding: 0.65rem 0.85rem;
|
||||||
|
text-decoration: none; color: inherit;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.loc-event-card:hover { border-color: var(--color-primary); }
|
||||||
|
.loc-event-thumb {
|
||||||
|
width: 48px; height: 48px; border-radius: 8px; flex-shrink: 0;
|
||||||
|
background: var(--color-card); overflow: hidden;
|
||||||
|
display: flex; align-items: center; justify-content: center; font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
.loc-event-thumb img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
.loc-event-body { flex: 1; min-width: 0; }
|
||||||
|
.loc-event-location { font-size: 0.75rem; color: var(--color-muted); margin-bottom: 0.1rem;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.loc-event-title { font-size: 0.92rem; font-weight: 600;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.loc-event-date { font-size: 0.75rem; color: var(--color-primary); margin-top: 0.15rem; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="app">
|
<body class="app">
|
||||||
@@ -132,36 +155,17 @@
|
|||||||
<div class="section-label">Matches 💕</div>
|
<div class="section-label">Matches 💕</div>
|
||||||
<div class="dating-strip" id="matchesStrip"></div>
|
<div class="dating-strip" id="matchesStrip"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Meine angemeldeten Events -->
|
||||||
|
<div id="myEventsSection" style="display:none;">
|
||||||
|
<div class="section-label">Meine Veranstaltungen 🎟</div>
|
||||||
|
<div class="loc-event-list" id="myEventsList"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="game-grid">
|
<!-- Nächste Events abonnierter Locations -->
|
||||||
<div class="game-card">
|
<div id="locEventsSection" style="display:none;">
|
||||||
<div class="game-card-icon">❤️</div>
|
<div class="section-label">Nächste Veranstaltungen 📍</div>
|
||||||
<h2 class="game-card-title">Vanilla Game</h2>
|
<div class="loc-event-list" id="locEventsList"></div>
|
||||||
<p class="game-card-desc">
|
|
||||||
|
|
||||||
</p>
|
|
||||||
<a href="/games/vanilla/sessionvanilla.html"><button class="game-card-btn">Neue Session starten</button></a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="game-card">
|
|
||||||
<div class="game-card-icon">⛓️</div>
|
|
||||||
<h2 class="game-card-title">BDSM Game</h2>
|
|
||||||
<p class="game-card-desc">
|
|
||||||
Tauche ein in strukturierte Sessions mit Aufgaben, Toys und klaren Rollen.
|
|
||||||
Definiere Grenzen, vergib Aufgaben und erlebe intensive Momente mit deinen Spielpartner*Innen.
|
|
||||||
</p>
|
|
||||||
<a href="/games/bdsm/neubdsm.html"><button class="game-card-btn">Neue Session starten</button></a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="game-card">
|
|
||||||
<div class="game-card-icon">🔒</div>
|
|
||||||
<h2 class="game-card-title">Chastity Game</h2>
|
|
||||||
<p class="game-card-desc">
|
|
||||||
Erlebe Keuschheit auf eine neue Art: Kartenbasierte Locks, Keyholder-System,
|
|
||||||
Community-Abstimmungen und tägliche Verifizierungen machen jedes Lock einzigartig.
|
|
||||||
</p>
|
|
||||||
<a href="/games/chastity/neulock.html"><button class="game-card-btn">Neues Lock erstellen</button></a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -178,6 +182,8 @@
|
|||||||
if (user) {
|
if (user) {
|
||||||
document.getElementById('greeting').textContent = 'Willkommen zurück, ' + user.name + '!';
|
document.getElementById('greeting').textContent = 'Willkommen zurück, ' + user.name + '!';
|
||||||
loadVisitors();
|
loadVisitors();
|
||||||
|
loadMyEvents();
|
||||||
|
loadLocEvents();
|
||||||
if (user.datingAktiv) {
|
if (user.datingAktiv) {
|
||||||
loadWhoLikesMe();
|
loadWhoLikesMe();
|
||||||
loadMatches();
|
loadMatches();
|
||||||
@@ -257,6 +263,47 @@
|
|||||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderEventCards(events, listId, sectionId) {
|
||||||
|
if (!events.length) return;
|
||||||
|
const list = document.getElementById(listId);
|
||||||
|
list.innerHTML = events.map(e => {
|
||||||
|
const thumb = e.imageData
|
||||||
|
? `<img src="data:image/jpeg;base64,${e.imageData}" alt="">`
|
||||||
|
: '🗓';
|
||||||
|
const date = new Date(e.startAt);
|
||||||
|
const dateStr = date.toLocaleDateString('de-DE', { weekday:'short', day:'numeric', month:'short' })
|
||||||
|
+ ', ' + date.toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' }) + ' Uhr';
|
||||||
|
return `
|
||||||
|
<a class="loc-event-card" href="/community/event-detail.html?id=${e.eventId}">
|
||||||
|
<div class="loc-event-thumb">${thumb}</div>
|
||||||
|
<div class="loc-event-body">
|
||||||
|
<div class="loc-event-location">${esc(e.locationName)}</div>
|
||||||
|
<div class="loc-event-title">${esc(e.title)}</div>
|
||||||
|
<div class="loc-event-date">${dateStr}</div>
|
||||||
|
</div>
|
||||||
|
</a>`;
|
||||||
|
}).join('');
|
||||||
|
document.getElementById(sectionId).style.display = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMyEvents() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/location-events/attending-next');
|
||||||
|
if (!res.ok) return;
|
||||||
|
const events = await res.json();
|
||||||
|
renderEventCards(events, 'myEventsList', 'myEventsSection');
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLocEvents() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/location-events/followed-next');
|
||||||
|
if (!res.ok) return;
|
||||||
|
const events = await res.json();
|
||||||
|
renderEventCards(events, 'locEventsList', 'locEventsSection');
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadVisitors() {
|
async function loadVisitors() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/social/profile-visits/my-visitors');
|
const res = await fetch('/social/profile-visits/my-visitors');
|
||||||
|
|||||||
Reference in New Issue
Block a user