Hashtags eingeführt
Some checks failed
Host-Based Deploy (Java 21 Fix) / build-and-run (push) Has been cancelled

This commit is contained in:
2026-04-11 01:14:33 +02:00
parent ec1409820b
commit e2a71ab096
57 changed files with 2365 additions and 740 deletions

3
.gitignore vendored
View File

@@ -4,3 +4,6 @@
# Ignore Gradle build output directory # Ignore Gradle build output directory
build build
.aider* .aider*
# Secrets niemals einchecken
.env

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -68,6 +68,11 @@
.empty-hint { color:var(--color-muted); font-size:0.9rem; margin-top:0.5rem; } .empty-hint { color:var(--color-muted); font-size:0.9rem; margin-top:0.5rem; }
.sentinel { height:1px; } .sentinel { height:1px; }
.hashtag-banner { display:flex; align-items:center; gap:0.75rem; margin-bottom:1.25rem; padding:0.65rem 1rem; background:var(--color-card); border:1px solid var(--color-secondary); border-radius:8px; }
.hashtag-banner-tag { font-size:1.05rem; font-weight:700; color:var(--color-primary); }
.hashtag-banner-back { margin-left:auto; font-size:0.82rem; color:var(--color-muted); text-decoration:none; }
.hashtag-banner-back:hover { color:var(--color-primary); }
/* Lightbox */ /* Lightbox */
.lightbox { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.88); z-index:300; align-items:center; justify-content:center; } .lightbox { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.88); z-index:300; align-items:center; justify-content:center; }
.lightbox.open { display:flex; } .lightbox.open { display:flex; }
@@ -92,7 +97,13 @@
<div class="main"> <div class="main">
<div class="content"> <div class="content">
<div class="tabs"> <!-- Hashtag-Banner (nur sichtbar wenn ?tag=… gesetzt) -->
<div class="hashtag-banner" id="hashtagBanner" style="display:none;">
<span>Posts mit </span><span class="hashtag-banner-tag" id="hashtagBannerLabel"></span>
<a class="hashtag-banner-back" href="/community/feed.html">× Zurück zum Feed</a>
</div>
<div class="tabs" id="feedTabs">
<button class="tab-btn active" id="tabMine" data-tab="mine" onclick="switchTab('mine', this)">Mein Feed</button> <button class="tab-btn active" id="tabMine" data-tab="mine" onclick="switchTab('mine', this)">Mein Feed</button>
<button class="tab-btn" id="tabPublic" data-tab="public" onclick="switchTab('public', this)">Öffentlicher Feed</button> <button class="tab-btn" id="tabPublic" data-tab="public" onclick="switchTab('public', this)">Öffentlicher Feed</button>
</div> </div>
@@ -140,6 +151,13 @@
<div class="sentinel" id="publicSentinel"></div> <div class="sentinel" id="publicSentinel"></div>
</div> </div>
<!-- Hashtag-Feed (wird angezeigt wenn ?tag=… gesetzt) -->
<div id="tab-hashtag" style="display:none;">
<div id="hashtagFeed"></div>
<p class="empty-hint" id="hashtagEmpty" style="display:none;">Keine Posts mit diesem Hashtag.</p>
<div class="sentinel" id="hashtagSentinel"></div>
</div>
</div> </div>
</div> </div>
@@ -165,37 +183,68 @@
<script src="/js/shared.js"></script> <script src="/js/shared.js"></script>
<script src="/js/icons.js"></script> <script src="/js/icons.js"></script>
<script src="/js/nav.js"></script> <script src="/js/nav.js"></script>
<script src="/js/social-sidebar.js"></script> <script src="/js/social-sidebar.js"></script>
<script src="/js/meldung.js"></script> <script src="/js/meldung.js"></script>
<script src="/js/hashtag.js"></script>
<script> <script>
// ── State ── // ── State ──
let myUserId = null; let myUserId = null;
let activeLbPostId = null; let activeLbPostId = null;
let activeLbPostType = null; let activeLbPostType = null;
let activeHashtag = null; // set when ?tag=... is in URL
const feedState = { const feedState = {
mine: { page:0, hasMore:true, loading:false, loaded:false }, mine: { page:0, hasMore:true, loading:false, loaded:false },
public: { page:0, hasMore:true, loading:false, loaded:false } public: { page:0, hasMore:true, loading:false, loaded:false },
hashtag: { page:0, hasMore:true, loading:false, loaded:false }
}; };
let composeBilderArr = []; let composeBilderArr = [];
// ── Hashtag-Modus prüfen ──
const _urlTag = new URLSearchParams(window.location.search).get('tag');
if (_urlTag) {
activeHashtag = _urlTag.replace(/^#/, '').toLowerCase();
document.getElementById('hashtagBanner').style.display = '';
document.getElementById('hashtagBannerLabel').textContent = '#' + activeHashtag;
document.getElementById('feedTabs').style.display = 'none';
document.getElementById('tab-mine').style.display = 'none';
document.getElementById('tab-public').style.display = 'none';
document.getElementById('tab-hashtag').style.display = '';
document.getElementById('compose').style.display = 'none';
}
// ── Boot ── // ── Boot ──
fetch('/login/me').then(r => r.ok ? r.json() : null).then(async user => { fetch('/login/me').then(r => r.ok ? r.json() : null).then(async user => {
if (user) { if (user) {
myUserId = user.userId; myUserId = user.userId;
const raw = sessionStorage.getItem('feedOpenPost'); if (activeHashtag) {
if (raw) { await loadFeed('hashtag');
sessionStorage.removeItem('feedOpenPost');
loadFeed('mine');
openLbWithData(JSON.parse(raw));
} else { } else {
await loadFeed('mine'); const raw = sessionStorage.getItem('feedOpenPost');
if (raw) {
sessionStorage.removeItem('feedOpenPost');
loadFeed('mine');
openLbWithData(JSON.parse(raw));
} else {
await loadFeed('mine');
}
} }
} }
}).catch(() => {}); }).catch(() => {});
// ── Autocomplete für Compose ──
document.addEventListener('DOMContentLoaded', () => {
const ta = document.getElementById('composeText');
if (ta) attachHashtagAutocomplete(ta);
});
// Fallback falls DOMContentLoaded bereits gefeuert
if (document.readyState !== 'loading') {
const ta = document.getElementById('composeText');
if (ta) attachHashtagAutocomplete(ta);
}
// ── Tab switching ── // ── Tab switching ──
function switchTab(name, btn) { function switchTab(name, btn) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
@@ -218,8 +267,14 @@
state.loading = true; state.loading = true;
state.loaded = true; state.loaded = true;
try { try {
const endpoint = tab === 'mine' ? '/feed/mine' : '/feed/public'; let url;
const res = await fetch(`${endpoint}?page=${state.page}&size=10`); if (tab === 'hashtag') {
url = `/feed/hashtag?tag=${encodeURIComponent(activeHashtag)}&page=${state.page}&size=10`;
} else {
const base = tab === 'mine' ? '/feed/mine' : '/feed/public';
url = `${base}?page=${state.page}&size=10`;
}
const res = await fetch(url);
if (!res.ok) return; if (!res.ok) return;
const data = await res.json(); const data = await res.json();
const feedEl = document.getElementById(tab + 'Feed'); const feedEl = document.getElementById(tab + 'Feed');
@@ -238,12 +293,14 @@
const observer = new IntersectionObserver(entries => { const observer = new IntersectionObserver(entries => {
entries.forEach(e => { entries.forEach(e => {
if (!e.isIntersecting) return; if (!e.isIntersecting) return;
if (e.target.id === 'mineSentinel') loadFeed('mine'); if (e.target.id === 'mineSentinel') loadFeed('mine');
if (e.target.id === 'publicSentinel') loadFeed('public'); if (e.target.id === 'publicSentinel') loadFeed('public');
if (e.target.id === 'hashtagSentinel') loadFeed('hashtag');
}); });
}, { threshold: 0.5 }); }, { threshold: 0.5 });
observer.observe(document.getElementById('mineSentinel')); observer.observe(document.getElementById('mineSentinel'));
observer.observe(document.getElementById('publicSentinel')); observer.observe(document.getElementById('publicSentinel'));
observer.observe(document.getElementById('hashtagSentinel'));
// bilderCarousel und carNav kommen aus shared.js // bilderCarousel und carNav kommen aus shared.js
@@ -297,7 +354,7 @@
</div> </div>
${deleteBtn} ${deleteBtn}
</div> </div>
<div class="post-text">${esc(p.text)}</div> <div class="post-text">${renderTextWithHashtags(p.text)}</div>
${bildHtml} ${bildHtml}
${umfrageHtml} ${umfrageHtml}
<div class="post-actions"> <div class="post-actions">

View File

@@ -143,7 +143,7 @@
<!-- Friends tab --> <!-- Friends tab -->
<div class="tab-panel active" id="tab-friends"> <div class="tab-panel active" id="tab-friends">
<ul class="user-list" id="friendsList"></ul> <ul class="user-list" id="friendsList"></ul>
<p class="empty-hint" id="friendsEmpty" style="display:none;">Du hast noch keine Freunde. <a href="/community/personen-suchen.html" style="color:var(--color-primary);">Personen suchen</a></p> <p class="empty-hint" id="friendsEmpty" style="display:none;">Du hast noch keine Freunde. <a href="/search.html" style="color:var(--color-primary);">Personen suchen</a></p>
</div> </div>
<!-- Pending tab --> <!-- Pending tab -->

View File

@@ -297,8 +297,9 @@
<script src="/js/shared.js"></script> <script src="/js/shared.js"></script>
<script src="/js/icons.js"></script> <script src="/js/icons.js"></script>
<script src="/js/nav.js"></script> <script src="/js/nav.js"></script>
<script src="/js/social-sidebar.js"></script> <script src="/js/social-sidebar.js"></script>
<script src="/js/hashtag.js"></script>
<script> <script>
// ── Generic modal helpers ── // ── Generic modal helpers ──
@@ -377,6 +378,9 @@
await loadGruppe(); await loadGruppe();
await loadPosts(); await loadPosts();
const _composeText = document.getElementById('composeText');
if (_composeText) attachHashtagAutocomplete(_composeText);
const _savedTab = localStorage.getItem('tab_gruppe_' + gruppeId); const _savedTab = localStorage.getItem('tab_gruppe_' + gruppeId);
if (_savedTab) { if (_savedTab) {
const _btn = document.querySelector(`.tab-btn[data-tab="${_savedTab}"]`); const _btn = document.querySelector(`.tab-btn[data-tab="${_savedTab}"]`);
@@ -488,7 +492,7 @@
}).join(''); }).join('');
body = bars + `<div class="umfrage-total">${total} Stimme${total !== 1 ? 'n' : ''} gesamt${p.multiChoice?' · Multi-Choice':''}</div>`; body = bars + `<div class="umfrage-total">${total} Stimme${total !== 1 ? 'n' : ''} gesamt${p.multiChoice?' · Multi-Choice':''}</div>`;
} else { } else {
body = `<div class="post-text">${esc(p.text)}</div>${bildHtml}`; body = `<div class="post-text">${renderTextWithHashtags(p.text)}</div>${bildHtml}`;
} }
return ` return `
@@ -500,7 +504,7 @@
</div> </div>
<div class="post-date">${fmtDate(p.createdAt)}</div> <div class="post-date">${fmtDate(p.createdAt)}</div>
</div> </div>
${p.beitragTyp === 'UMFRAGE' ? `<div style="font-weight:600;margin-bottom:0.5rem;">${esc(p.text)}</div>${bildHtml}` : ''} ${p.beitragTyp === 'UMFRAGE' ? `<div style="font-weight:600;margin-bottom:0.5rem;">${renderTextWithHashtags(p.text)}</div>${bildHtml}` : ''}
${body} ${body}
<div class="post-actions"> <div class="post-actions">
<button class="post-action-btn ${p.likedByMe?'active':''}" onclick="event.stopPropagation(); toggleLike('${p.beitragId}',this)" id="like-btn-${p.beitragId}"> <button class="post-action-btn ${p.likedByMe?'active':''}" onclick="event.stopPropagation(); toggleLike('${p.beitragId}',this)" id="like-btn-${p.beitragId}">

View File

@@ -22,16 +22,9 @@
.gruppe-card-body { padding:0.85rem; flex:1; display:flex; flex-direction:column; gap:0.4rem; } .gruppe-card-body { padding:0.85rem; flex:1; display:flex; flex-direction:column; gap:0.4rem; }
.gruppe-card-name { font-weight:700; font-size:1rem; } .gruppe-card-name { font-weight:700; font-size:1rem; }
.gruppe-card-meta { font-size:0.78rem; color:var(--color-muted); } .gruppe-card-meta { font-size:0.78rem; color:var(--color-muted); }
.gruppe-card-actions { display:flex; gap:0.5rem; flex-wrap:wrap; margin-top:auto; padding-top:0.5rem; }
.gruppe-card-actions button, .gruppe-card-actions a.btn { margin-top:0; padding:0.3rem 0.7rem; font-size:0.8rem; width:auto; }
.role-badge { font-size:0.7rem; font-weight:700; padding:0.1rem 0.4rem; border-radius:4px; background:var(--color-primary); color:#fff; display:inline-block; } .role-badge { font-size:0.7rem; font-weight:700; padding:0.1rem 0.4rem; border-radius:4px; background:var(--color-primary); color:#fff; display:inline-block; }
.role-badge.mitglied { background:var(--color-secondary); color:var(--color-text); } .role-badge.mitglied { background:var(--color-secondary); color:var(--color-text); }
.search-row { display:flex; gap:0.75rem; margin-bottom:1rem; }
.search-row input { flex:1; }
.search-row button { white-space:nowrap; width:auto; margin-top:0; }
.anfrage-list { list-style:none; margin:0; padding:0; } .anfrage-list { list-style:none; margin:0; padding:0; }
.anfrage-item { display:flex; align-items:center; justify-content:space-between; gap:1rem; padding:0.75rem 0; border-bottom:1px solid var(--color-secondary); } .anfrage-item { display:flex; align-items:center; justify-content:space-between; gap:1rem; padding:0.75rem 0; border-bottom:1px solid var(--color-secondary); }
.anfrage-item:last-child { border-bottom:none; } .anfrage-item:last-child { border-bottom:none; }
@@ -64,12 +57,14 @@
<div class="content"> <div class="content">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:1.25rem;"> <div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:1.25rem;">
<h1 style="margin:0;">Gruppen</h1> <h1 style="margin:0;">Gruppen</h1>
<button onclick="openCreateDialog()" style="width:auto; padding:0.4rem 1rem; font-size:0.9rem; margin:0;">+ Erstellen</button> <div style="display:flex; gap:0.5rem;">
<button onclick="location.href='/search.html?tab=gruppen'" style="width:auto; padding:0.4rem 1rem; font-size:0.9rem; margin:0;">🔍 Suchen</button>
<button onclick="openCreateDialog()" style="width:auto; padding:0.4rem 1rem; font-size:0.9rem; margin:0;">+ Erstellen</button>
</div>
</div> </div>
<div class="tabs"> <div class="tabs">
<button class="tab-btn active" data-tab="mine" onclick="switchTab('mine', this)">Meine Gruppen</button> <button class="tab-btn active" data-tab="mine" onclick="switchTab('mine', this)">Meine Gruppen</button>
<button class="tab-btn" data-tab="discover" onclick="switchTab('discover', this)">Entdecken</button>
<button class="tab-btn" data-tab="requests" onclick="switchTab('requests', this)">Meine Anfragen</button> <button class="tab-btn" data-tab="requests" onclick="switchTab('requests', this)">Meine Anfragen</button>
</div> </div>
@@ -79,16 +74,6 @@
<p class="empty-hint" id="mineEmpty" style="display:none;">Du bist noch in keiner Gruppe.</p> <p class="empty-hint" id="mineEmpty" style="display:none;">Du bist noch in keiner Gruppe.</p>
</div> </div>
<!-- Entdecken -->
<div class="tab-panel" id="tab-discover">
<div class="search-row">
<input type="text" id="searchInput" placeholder="Gruppenname suchen…" onkeydown="if(event.key==='Enter')doSearch()">
<button onclick="doSearch()">Suchen</button>
</div>
<div class="gruppe-grid" id="discoverGrid"></div>
<p class="empty-hint" id="discoverHint">Gib einen Suchbegriff ein.</p>
</div>
<!-- Meine Anfragen --> <!-- Meine Anfragen -->
<div class="tab-panel" id="tab-requests"> <div class="tab-panel" id="tab-requests">
<ul class="anfrage-list" id="requestsList"></ul> <ul class="anfrage-list" id="requestsList"></ul>
@@ -121,20 +106,6 @@
</div> </div>
</div> </div>
<!-- Beitrittsanfrage Dialog -->
<div class="dialog-backdrop" id="joinDialog">
<div class="dialog">
<h3>Beitrittsanfrage senden</h3>
<p id="joinDialogGroupName" style="font-weight:600; margin-bottom:0.5rem;"></p>
<label>Nachricht (optional)</label>
<textarea id="joinNachricht" placeholder="Warum möchtest du beitreten?"></textarea>
<p class="message error" id="joinError" style="display:none; margin-top:0.75rem;"></p>
<div class="dialog-actions">
<button class="secondary" onclick="closeJoinDialog()">Abbrechen</button>
<button onclick="sendJoinRequest()">Anfrage senden</button>
</div>
</div>
</div>
<script src="/js/icons.js"></script> <script src="/js/icons.js"></script>
<script src="/js/nav.js"></script> <script src="/js/nav.js"></script>
@@ -157,7 +128,7 @@
function esc(s) { const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; } function esc(s) { const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; }
function gruppeCard(g, showJoin = false) { function gruppeCard(g) {
const img = g.bild const img = g.bild
? `<div class="gruppe-card-img"><img src="data:image/jpeg;base64,${g.bild}" alt=""></div>` ? `<div class="gruppe-card-img"><img src="data:image/jpeg;base64,${g.bild}" alt=""></div>`
: `<div class="gruppe-card-img">👥</div>`; : `<div class="gruppe-card-img">👥</div>`;
@@ -165,16 +136,6 @@
? `<span class="role-badge ${g.myRole === 'ADMIN' ? '' : 'mitglied'}">${g.myRole === 'ADMIN' ? 'Admin' : 'Mitglied'}</span>` ? `<span class="role-badge ${g.myRole === 'ADMIN' ? '' : 'mitglied'}">${g.myRole === 'ADMIN' ? 'Admin' : 'Mitglied'}</span>`
: ''; : '';
const privBadge = g.isPrivate ? ' 🔒' : ''; const privBadge = g.isPrivate ? ' 🔒' : '';
let actions = '';
if (showJoin && !g.myRole) {
if (g.myRequestStatus === 'AUSSTEHEND') {
actions = `<button disabled style="opacity:0.6;" onclick="event.stopPropagation()">Anfrage ausstehend</button>`;
} else if (g.isPrivate) {
actions = `<button onclick="event.stopPropagation(); openJoinDialog('${g.gruppeId}','${esc(g.name)}')">Anfrage senden</button>`;
} else {
actions = `<button onclick="event.stopPropagation(); joinGruppe('${g.gruppeId}', this)">Beitreten</button>`;
}
}
return ` return `
<div class="gruppe-card" id="gc-${g.gruppeId}" onclick="location.href='/community/gruppe.html?gruppeId=${g.gruppeId}'" style="cursor:pointer;"> <div class="gruppe-card" id="gc-${g.gruppeId}" onclick="location.href='/community/gruppe.html?gruppeId=${g.gruppeId}'" style="cursor:pointer;">
${img} ${img}
@@ -183,7 +144,6 @@
<div class="gruppe-card-meta">${g.memberCount} Mitglied${g.memberCount !== 1 ? 'er' : ''} · ${g.postCount} Beiträge</div> <div class="gruppe-card-meta">${g.memberCount} Mitglied${g.memberCount !== 1 ? 'er' : ''} · ${g.postCount} Beiträge</div>
${g.beschreibung ? `<div style="font-size:0.82rem;color:var(--color-muted);">${esc(g.beschreibung.substring(0,80))}${g.beschreibung.length>80?'…':''}</div>` : ''} ${g.beschreibung ? `<div style="font-size:0.82rem;color:var(--color-muted);">${esc(g.beschreibung.substring(0,80))}${g.beschreibung.length>80?'…':''}</div>` : ''}
<div class="card-notif" id="notif-${g.gruppeId}"></div> <div class="card-notif" id="notif-${g.gruppeId}"></div>
${actions ? `<div class="gruppe-card-actions">${actions}</div>` : ''}
</div> </div>
</div>`; </div>`;
} }
@@ -223,37 +183,6 @@
})); }));
} }
async function doSearch() {
const q = document.getElementById('searchInput').value.trim();
if (!q) return;
try {
const res = await fetch('/gruppen/search?q=' + encodeURIComponent(q));
if (!res.ok) return;
const data = await res.json();
const grid = document.getElementById('discoverGrid');
const hint = document.getElementById('discoverHint');
grid.innerHTML = '';
if (data.length === 0) { hint.textContent = 'Keine Gruppen gefunden.'; hint.style.display = ''; return; }
hint.style.display = 'none';
data.forEach(g => grid.insertAdjacentHTML('beforeend', gruppeCard(g, true)));
} catch(e) { console.error(e); }
}
async function joinGruppe(gruppeId, btn) {
btn.disabled = true;
btn.textContent = '…';
try {
const res = await fetch('/gruppen/' + gruppeId + '/join', { method: 'POST', headers:{'Content-Type':'application/json'}, body:'{}' });
if (res.ok || res.status === 201) {
btn.textContent = 'Beigetreten ✓';
setTimeout(loadMine, 500);
} else {
btn.disabled = false;
btn.textContent = 'Beitreten';
}
} catch(e) { btn.disabled = false; btn.textContent = 'Beitreten'; }
}
async function loadRequests() { async function loadRequests() {
try { try {
const reqRes = await fetch('/gruppen/requests/mine'); const reqRes = await fetch('/gruppen/requests/mine');
@@ -333,42 +262,6 @@
} catch(e) { showCreateError('Fehler: ' + e.message); } } catch(e) { showCreateError('Fehler: ' + e.message); }
} }
// ── Join dialog ──
let pendingJoinGruppeId = null;
function openJoinDialog(gruppeId, name) {
pendingJoinGruppeId = gruppeId;
document.getElementById('joinDialogGroupName').textContent = name;
document.getElementById('joinNachricht').value = '';
document.getElementById('joinDialog').classList.add('visible');
}
function closeJoinDialog() { document.getElementById('joinDialog').classList.remove('visible'); pendingJoinGruppeId = null; }
async function sendJoinRequest() {
if (!pendingJoinGruppeId) return;
document.getElementById('joinError').style.display = 'none';
const nachricht = document.getElementById('joinNachricht').value.trim() || null;
try {
const res = await fetch('/gruppen/' + pendingJoinGruppeId + '/join', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ nachricht })
});
if (res.ok || res.status === 201) {
closeJoinDialog();
doSearch();
} else {
const el = document.getElementById('joinError');
el.textContent = 'Fehler beim Senden der Anfrage.';
el.style.display = 'block';
}
} catch(e) {
const el = document.getElementById('joinError');
el.textContent = 'Fehler: ' + e.message;
el.style.display = 'block';
}
}
// ── Image preview ── // ── Image preview ──
function previewBild(input, previewId, dataId) { function previewBild(input, previewId, dataId) {
@@ -401,9 +294,6 @@
document.getElementById('createDialog').addEventListener('click', e => { document.getElementById('createDialog').addEventListener('click', e => {
if (e.target === document.getElementById('createDialog')) closeCreateDialog(); if (e.target === document.getElementById('createDialog')) closeCreateDialog();
}); });
document.getElementById('joinDialog').addEventListener('click', e => {
if (e.target === document.getElementById('joinDialog')) closeJoinDialog();
});
loadMine(); loadMine();
</script> </script>

View File

@@ -360,7 +360,7 @@
const list = document.getElementById('convList'); const list = document.getElementById('convList');
list.innerHTML = ''; list.innerHTML = '';
if (convs.length === 0) { if (convs.length === 0) {
list.innerHTML = '<li style="padding:1rem; color:var(--color-muted); font-size:0.9rem;">Noch keine Nachrichten. <a href="/community/personen-suchen.html" style="color:var(--color-primary);">Personen suchen</a></li>'; list.innerHTML = '<li style="padding:1rem; color:var(--color-muted); font-size:0.9rem;">Noch keine Nachrichten. <a href="/search.html" style="color:var(--color-primary);">Personen suchen</a></li>';
return; return;
} }
convs.forEach(c => { convs.forEach(c => {

View File

@@ -1,206 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Personen suchen xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.search-bar {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.search-bar input { flex: 1; }
.search-bar button { width: auto; margin-top: 0; padding: 0.65rem 1.25rem; }
.user-list { list-style: none; margin: 0; padding: 0; }
.user-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 0;
border-bottom: 1px solid var(--color-secondary);
}
.user-item:last-child { border-bottom: none; }
.user-avatar {
width: 42px; height: 42px;
border-radius: 50%;
background: var(--color-secondary);
display: flex; align-items: center; justify-content: center;
font-size: 1.2rem;
flex-shrink: 0;
overflow: hidden;
border: 1px solid var(--color-secondary);
}
.user-avatar img { width: 100%; height: 100%; object-fit: cover; }
.user-name { font-weight: 600; flex: 1; }
.user-actions { display: flex; gap: 0.5rem; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; }
.user-actions button, .user-actions a.btn {
margin-top: 0;
padding: 0.35rem 0.75rem;
font-size: 0.8rem;
width: auto;
}
.user-profile-link {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
min-width: 0;
text-decoration: none;
color: inherit;
}
.user-profile-link:hover .user-name { color: var(--color-primary); }
.hint { color: var(--color-muted); font-size: 0.9rem; margin-top: 0.5rem; }
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<h1 style="margin-bottom: 1.25rem;">Personen suchen</h1>
<div class="search-bar">
<input type="text" id="searchInput" placeholder="Name eingeben (mind. 2 Zeichen)…" autocomplete="off">
<button onclick="doSearch()">Suchen</button>
</div>
<ul class="user-list" id="resultList"></ul>
<p class="hint" id="hint">Gib mindestens 2 Zeichen ein, um zu suchen.</p>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/nav.js"></script>
<script src="/js/social-sidebar.js"></script>
<script>
let debounceTimer;
document.getElementById('searchInput').addEventListener('input', function () {
clearTimeout(debounceTimer);
const q = this.value.trim();
if (q.length < 2) {
document.getElementById('resultList').innerHTML = '';
document.getElementById('hint').textContent = 'Gib mindestens 2 Zeichen ein, um zu suchen.';
document.getElementById('hint').style.display = '';
return;
}
document.getElementById('hint').style.display = 'none';
debounceTimer = setTimeout(doSearch, 400);
});
document.getElementById('searchInput').addEventListener('keydown', e => {
if (e.key === 'Enter') { clearTimeout(debounceTimer); doSearch(); }
});
async function doSearch() {
const q = document.getElementById('searchInput').value.trim();
if (q.length < 2) return;
try {
const res = await fetch('/social/users/search?q=' + encodeURIComponent(q));
if (!res.ok) return;
renderResults(await res.json());
} catch (e) { console.error(e); }
}
function renderResults(users) {
const list = document.getElementById('resultList');
const hint = document.getElementById('hint');
list.innerHTML = '';
if (users.length === 0) {
hint.textContent = 'Keine Ergebnisse gefunden.';
hint.style.display = '';
return;
}
hint.style.display = 'none';
users.forEach(u => {
const avatar = u.profilePicture
? `<img src="data:image/png;base64,${u.profilePicture}" alt="">`
: '◉';
list.insertAdjacentHTML('beforeend', `
<li class="user-item" data-user-id="${u.userId}">
<a href="/community/benutzer.html?userId=${u.userId}" class="user-profile-link">
<div class="user-avatar">${avatar}</div>
<div class="user-name">${esc(u.name)}</div>
</a>
<div class="user-actions">${buildActions(u)}</div>
</li>`);
});
}
function buildActions(u) {
if (u.friendStatus === 'FRIEND') {
return `<a href="/community/nachrichten.html?userId=${u.userId}" class="btn" style="background:var(--color-secondary); color:var(--color-text);">✉ Nachricht</a>`;
}
if (u.friendStatus === 'PENDING_SENT') {
return `<button disabled>Anfrage gesendet</button>`;
}
if (u.friendStatus === 'PENDING_RECEIVED') {
return `<button onclick="acceptByUserId('${u.userId}', this)">✓ Annehmen</button>`;
}
return `<button onclick="sendRequest('${u.userId}', this)">+ Freund hinzufügen</button>`;
}
async function sendRequest(receiverId, btn) {
btn.disabled = true;
btn.textContent = 'Wird gesendet…';
try {
const res = await fetch('/social/friends/request', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ receiverId })
});
btn.textContent = (res.ok || res.status === 201 || res.status === 409) ? 'Anfrage gesendet' : 'Fehler';
} catch (e) {
btn.disabled = false;
btn.textContent = '+ Freund hinzufügen';
}
}
async function acceptByUserId(senderId, btn) {
btn.disabled = true;
btn.textContent = '…';
try {
const pendingRes = await fetch('/social/friends/pending');
const pending = await pendingRes.json();
const f = pending.find(p => p.user.userId === senderId);
if (!f) { btn.textContent = 'Fehler'; return; }
const res = await fetch('/social/friends/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ friendshipId: f.friendshipId })
});
if (res.ok) {
btn.textContent = '✓ Freund';
const item = btn.closest('.user-item');
if (item) {
item.querySelector('.user-actions').innerHTML =
`<a href="/community/nachrichten.html?userId=${senderId}" class="btn" style="background:var(--color-secondary); color:var(--color-text);">✉ Nachricht</a>`;
}
} else {
btn.disabled = false;
btn.textContent = '✓ Annehmen';
}
} catch (e) {
btn.disabled = false;
btn.textContent = '✓ Annehmen';
}
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
</script>
</body>
</html>

View File

@@ -0,0 +1,371 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Einladungen xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.inv-tabs {
display: flex;
gap: 0;
margin-bottom: 1.5rem;
border-bottom: 1px solid var(--color-secondary);
}
.inv-tab {
background: none;
border: none;
border-bottom: 2px solid transparent;
border-radius: 0;
padding: 0.5rem 1.1rem;
font-size: 0.95rem;
font-weight: 600;
color: var(--color-muted);
cursor: pointer;
margin: 0 0 -1px;
width: auto;
transition: color 0.15s, border-color 0.15s;
}
.inv-tab.active { color: var(--color-primary); border-bottom-color: var(--color-primary); }
.inv-tab:hover:not(.active) { color: var(--color-text); background: none; }
.inv-section-label {
font-size: 0.8rem; font-weight: 600; color: var(--color-muted);
text-transform: uppercase; letter-spacing: 0.05em;
margin: 1.5rem 0 0.65rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--color-secondary);
}
.inv-card {
display: flex; gap: 0.85rem; align-items: center;
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 12px;
padding: 0.85rem 1rem;
margin-bottom: 0.6rem;
text-decoration: none;
color: inherit;
transition: border-color 0.15s;
}
.inv-card:hover { border-color: var(--color-primary); }
.inv-avatar {
width: 44px; height: 44px; border-radius: 50%;
background: var(--color-secondary);
display: flex; align-items: center; justify-content: center;
font-size: 1.2rem; overflow: hidden; flex-shrink: 0;
}
.inv-avatar img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
.inv-body { flex: 1; min-width: 0; }
.inv-from { font-weight: 600; font-size: 0.95rem;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.inv-type { font-size: 0.78rem; color: var(--color-muted); margin-top: 0.15rem; }
.inv-actions { display: flex; gap: 0.5rem; flex-shrink: 0; flex-wrap: wrap; align-items: center; }
.inv-btn {
background: var(--color-primary);
color: #fff; border: none; border-radius: 8px;
padding: 0.38rem 0.9rem; font-size: 0.85rem; font-weight: 600;
cursor: pointer; margin: 0; width: auto;
text-decoration: none; display: inline-flex; align-items: center;
transition: opacity 0.15s;
}
.inv-btn:hover { opacity: 0.85; }
.inv-btn.outline {
background: none; border: 1px solid var(--color-secondary);
color: var(--color-muted);
}
.inv-btn.outline:hover { border-color: var(--color-primary); color: var(--color-primary); opacity: 1; }
.inv-arrow { font-size: 0.85rem; color: var(--color-primary); font-weight: 600; flex-shrink: 0; }
.inv-status { font-size: 0.8rem; color: var(--color-muted); }
.inv-empty { text-align: center; color: var(--color-muted); padding: 3rem 1rem; font-size: 0.95rem; }
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<h1 style="margin:0 0 1.25rem;">Einladungen</h1>
<div class="inv-tabs">
<button class="inv-tab" id="tabBtnErhalten" onclick="switchTab('erhalten')">Erhalten</button>
<button class="inv-tab" id="tabBtnGesendet" onclick="switchTab('gesendet')">Gesendet</button>
</div>
<!-- ── Erhalten ─────────────────────────────────────────────────── -->
<div id="panelErhalten">
<div id="loadingErhalten" style="text-align:center;color:var(--color-muted);padding:2rem 0;">Wird geladen…</div>
<div id="secVanilla" style="display:none;">
<div class="inv-section-label">🎭 Vanilla Game</div>
<div id="listVanilla"></div>
</div>
<div id="secBdsm" style="display:none;">
<div class="inv-section-label">⛓ BDSM Game</div>
<div id="listBdsm"></div>
</div>
<div id="secChastity" style="display:none;">
<div class="inv-section-label">🔒 Chastity Game</div>
<div id="listChastity"></div>
</div>
<div id="emptyErhalten" style="display:none;">
<div class="inv-empty">Du hast aktuell keine offenen Einladungen.</div>
</div>
</div>
<!-- ── Gesendet ─────────────────────────────────────────────────── -->
<div id="panelGesendet" style="display:none;">
<div id="loadingGesendet" style="text-align:center;color:var(--color-muted);padding:2rem 0;">Wird geladen…</div>
<div id="secSentVanilla" style="display:none;">
<div class="inv-section-label">🎭 Vanilla Game</div>
<div id="listSentVanilla"></div>
</div>
<div id="secSentBdsm" style="display:none;">
<div class="inv-section-label">⛓ BDSM Game</div>
<div id="listSentBdsm"></div>
</div>
<div id="secSentChastity" style="display:none;">
<div class="inv-section-label">🔒 Chastity Game</div>
<div id="listSentChastity"></div>
</div>
<div id="emptySent" style="display:none;">
<div class="inv-empty">Du hast aktuell keine gesendeten Einladungen.</div>
</div>
</div>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/nav.js"></script>
<script>
function esc(s) {
return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ── Tab-Switching ────────────────────────────────────────────────────────
function switchTab(tab) {
const erhalten = tab === 'erhalten';
document.getElementById('panelErhalten').style.display = erhalten ? '' : 'none';
document.getElementById('panelGesendet').style.display = erhalten ? 'none' : '';
document.getElementById('tabBtnErhalten').classList.toggle('active', erhalten);
document.getElementById('tabBtnGesendet').classList.toggle('active', !erhalten);
const url = new URL(location.href);
erhalten ? url.searchParams.delete('tab') : url.searchParams.set('tab', tab);
history.replaceState(null, '', url);
}
const initTab = new URLSearchParams(location.search).get('tab') || 'erhalten';
switchTab(initTab);
// ── Erhalten ──────────────────────────────────────────────────────────────
async function loadErhalten() {
let shown = 0;
try {
const [vRes, bRes, cRes] = await Promise.all([
fetch('/vanilla/einladung/pending'),
fetch('/bdsm/einladung/pending'),
fetch('/lockee/invitations/mine'),
]);
// Vanilla
if (vRes.ok) {
const list = await vRes.json();
if (list.length) {
document.getElementById('secVanilla').style.display = '';
document.getElementById('listVanilla').innerHTML = list.map(e => `
<div class="inv-card" id="vcard-${esc(e.id)}">
<div class="inv-avatar">
${e.inviterAvatar
? `<img src="data:image/png;base64,${e.inviterAvatar}" alt="">`
: '🎭'}
</div>
<div class="inv-body">
<div class="inv-from">${esc(e.inviterName || 'Jemand')}</div>
<div class="inv-type">Vanilla-Spieleinladung</div>
</div>
<div class="inv-actions">
<button class="inv-btn" onclick="acceptVanilla(${esc(e.id)})">Annehmen</button>
<button class="inv-btn outline" onclick="declineVanilla(${esc(e.id)})">Ablehnen</button>
</div>
</div>`).join('');
shown += list.length;
}
}
// BDSM
if (bRes.ok) {
const list = await bRes.json();
if (list.length) {
document.getElementById('secBdsm').style.display = '';
document.getElementById('listBdsm').innerHTML = list.map(e => `
<a class="inv-card" href="/games/bdsm/bdsm-einladung.html?id=${esc(e.id)}">
<div class="inv-avatar">
${e.inviterAvatar
? `<img src="data:image/png;base64,${e.inviterAvatar}" alt="">`
: '⛓'}
</div>
<div class="inv-body">
<div class="inv-from">${esc(e.inviterName || 'Jemand')}</div>
<div class="inv-type">BDSM-Spieleinladung</div>
</div>
<span class="inv-arrow">Ansehen →</span>
</a>`).join('');
shown += list.length;
}
}
// Chastity
if (cRes.ok) {
const list = await cRes.json();
if (list.length) {
document.getElementById('secChastity').style.display = '';
document.getElementById('listChastity').innerHTML = list.map(e => `
<a class="inv-card" href="/games/chastity/joinlock.html?token=${esc(e.token)}">
<div class="inv-avatar">
${e.keyholderProfilePic
? `<img src="data:image/png;base64,${e.keyholderProfilePic}" alt="">`
: '🔒'}
</div>
<div class="inv-body">
<div class="inv-from">${esc(e.keyholderName || 'Jemand')}</div>
<div class="inv-type">Keuschheitslock: ${esc(e.lockName || '')}</div>
</div>
<span class="inv-arrow">Ansehen →</span>
</a>`).join('');
shown += list.length;
}
}
} catch (_) {}
document.getElementById('loadingErhalten').style.display = 'none';
if (!shown) document.getElementById('emptyErhalten').style.display = '';
}
// Vanilla inline annehmen / ablehnen
async function acceptVanilla(id) {
const card = document.getElementById('vcard-' + id);
if (card) card.querySelector('.inv-actions').innerHTML = '<span class="inv-status">Wird gespeichert…</span>';
try {
const res = await fetch(`/vanilla/einladung/${id}/antwort`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accepted: true, mode: 'OWN_DEVICE' }),
});
if (res.ok) {
window.location.href = '/games/vanilla/neuvanilla.html';
} else {
if (card) card.querySelector('.inv-actions').innerHTML = '<span class="inv-status" style="color:var(--color-primary)">Fehler beim Annehmen.</span>';
}
} catch (_) {
if (card) card.querySelector('.inv-actions').innerHTML = '<span class="inv-status" style="color:var(--color-primary)">Fehler.</span>';
}
}
async function declineVanilla(id) {
const card = document.getElementById('vcard-' + id);
if (card) card.querySelector('.inv-actions').innerHTML = '<span class="inv-status">Wird gespeichert…</span>';
try {
await fetch(`/vanilla/einladung/${id}/antwort`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accepted: false, mode: null }),
});
if (card) card.remove();
if (!document.getElementById('listVanilla').children.length) {
document.getElementById('secVanilla').style.display = 'none';
}
checkEmptyErhalten();
} catch (_) {}
}
function checkEmptyErhalten() {
const anyVisible = ['secVanilla','secBdsm','secChastity']
.some(id => document.getElementById(id).style.display !== 'none');
document.getElementById('emptyErhalten').style.display = anyVisible ? 'none' : '';
}
// ── Gesendet ──────────────────────────────────────────────────────────────
async function loadGesendet() {
let shown = 0;
try {
const [bRes, vRes, cRes] = await Promise.all([
fetch('/bdsm/einladung/meine-aktive'),
fetch('/vanilla/einladung/meine-aktive'),
fetch('/keyholder/invitations/mine'),
]);
// Vanilla gesendete Einladung
if (vRes.ok) {
const e = await vRes.json();
if (e) {
document.getElementById('secSentVanilla').style.display = '';
document.getElementById('listSentVanilla').innerHTML = `
<div class="inv-card">
<div class="inv-avatar">🎭</div>
<div class="inv-body">
<div class="inv-from">Vanilla-Einladung gesendet</div>
<div class="inv-type">Warte auf Antwort…</div>
</div>
<a class="inv-btn" href="/games/vanilla/neuvanilla.html">Zum Spiel →</a>
</div>`;
shown++;
}
}
// BDSM gesendete Einladung
if (bRes.ok) {
const e = await bRes.json();
if (e) {
document.getElementById('secSentBdsm').style.display = '';
document.getElementById('listSentBdsm').innerHTML = `
<div class="inv-card">
<div class="inv-avatar">⛓</div>
<div class="inv-body">
<div class="inv-from">BDSM-Einladung gesendet</div>
<div class="inv-type">Warte auf Antwort…</div>
</div>
<a class="inv-btn" href="/games/bdsm/neubdsm.html">Zum Spiel →</a>
</div>`;
shown++;
}
}
// Chastity gesendete Einladungen (als Keyholder)
if (cRes.ok) {
const list = await cRes.json();
if (Array.isArray(list) && list.length) {
document.getElementById('secSentChastity').style.display = '';
document.getElementById('listSentChastity').innerHTML = list.map(e => `
<div class="inv-card">
<div class="inv-avatar">🔒</div>
<div class="inv-body">
<div class="inv-from">${esc(e.lockeeName || e.lockeeEmail || 'Eingeladene Person')}</div>
<div class="inv-type">Lock: ${esc(e.lockName || '')} · ${esc(e.status || 'Ausstehend')}</div>
</div>
<a class="inv-btn outline" href="/games/chastity/keyholder.html">Keyholder →</a>
</div>`).join('');
shown += list.length;
}
}
} catch (_) {}
document.getElementById('loadingGesendet').style.display = 'none';
if (!shown) document.getElementById('emptySent').style.display = '';
}
// Auth-Check & Start
fetch('/login/me')
.then(r => { if (r.status === 401) window.location.href = '/login.html'; return r.ok ? r.json() : null; })
.then(user => {
if (user) { loadErhalten(); loadGesendet(); }
})
.catch(() => { window.location.href = '/login.html'; });
</script>
</body>
</html>

View File

@@ -1213,6 +1213,7 @@
const id = addPlayer(p.name, i === 0, i === 0, false, false); const id = addPlayer(p.name, i === 0, i === 0, false, false);
if (id) { restorePlayer(id, p); if (p.userId) userIdToInfo[p.userId] = { playerId: id, name: p.name }; } if (id) { restorePlayer(id, p); if (p.userId) userIdToInfo[p.userId] = { playerId: id, name: p.name }; }
}); });
Object.entries(userIdToInfo).forEach(([userId, info]) => { pruefeChastityConstraint(info.playerId, userId); });
await ladeEinladungenAusDb(userIdToInfo); await ladeEinladungenAusDb(userIdToInfo);
restoredFromSetup = true; restoredFromSetup = true;
} else { } else {
@@ -1273,14 +1274,15 @@
if (draft.gruppenJson) { sessionStorage.setItem('vanilla-session-gruppen', draft.gruppenJson); savedGruppen = new Set(JSON.parse(draft.gruppenJson)); } if (draft.gruppenJson) { sessionStorage.setItem('vanilla-session-gruppen', draft.gruppenJson); savedGruppen = new Set(JSON.parse(draft.gruppenJson)); }
} }
} }
const selfGeschlecht = user?.geschlecht || null; const defaults = await fetch('/user/me/bdsm-defaults').then(r => r.ok ? r.json() : {}).catch(() => ({}));
const selfWerkzeuge = selfGeschlecht ? (WERKZEUGE_DEFAULTS[selfGeschlecht] || []) : []; const selfGeschlecht = defaults.geschlecht || user?.geschlecht || null;
const selfWerkzeuge = defaults.werkzeuge?.length ? defaults.werkzeuge : (selfGeschlecht ? (WERKZEUGE_DEFAULTS[selfGeschlecht] || []) : []);
const selfId = addPlayer(user ? user.name : '', true, true, !!selfGeschlecht, false); const selfId = addPlayer(user ? user.name : '', true, true, !!selfGeschlecht, false);
if (selfId) { if (selfId) {
if (user?.profilePicture) { playerProfilePics[selfId] = user.profilePicture; updatePlayerHeader(selfId, user.name); } if (user?.profilePicture) { playerProfilePics[selfId] = user.profilePicture; updatePlayerHeader(selfId, user.name); }
if (playerIds.length < MAX_PLAYERS) addPlayer(); if (playerIds.length < MAX_PLAYERS) addPlayer();
restorePlayer(selfId, { geschlecht: selfGeschlecht, spieltMit: [], rollen: [], werkzeuge: selfWerkzeuge }); restorePlayer(selfId, { geschlecht: selfGeschlecht, spieltMit: defaults.spieltMit || [], rollen: defaults.rollen || [], werkzeuge: selfWerkzeuge });
pruefeChastityConstraint(selfId, myUserId); if (myUserId) pruefeChastityConstraint(selfId, myUserId);
} }
await ladeEinladungenAusDb(null); await ladeEinladungenAusDb(null);
} }
@@ -1324,8 +1326,9 @@
if (user?.profilePicture) { playerProfilePics[guestOwnPlayerId] = user.profilePicture; updatePlayerHeader(guestOwnPlayerId, user?.name || ''); } if (user?.profilePicture) { playerProfilePics[guestOwnPlayerId] = user.profilePicture; updatePlayerHeader(guestOwnPlayerId, user?.name || ''); }
restorePlayer(guestOwnPlayerId, { geschlecht: user?.geschlecht || null, spieltMit: [], rollen: [], werkzeuge: [] }); const defaults = await fetch('/user/me/bdsm-defaults').then(r => r.ok ? r.json() : {}).catch(() => ({}));
pruefeChastityConstraint(guestOwnPlayerId, myUserId); restorePlayer(guestOwnPlayerId, { geschlecht: defaults.geschlecht || user?.geschlecht || null, spieltMit: defaults.spieltMit || [], rollen: defaults.rollen || [], werkzeuge: defaults.werkzeuge || [] });
if (myUserId) pruefeChastityConstraint(guestOwnPlayerId, myUserId);
document.getElementById('acc-grundeinstellungen-btn').classList.add('is-open'); document.getElementById('acc-grundeinstellungen-btn').classList.add('is-open');
document.getElementById('acc-grundeinstellungen-body').classList.add('is-open'); document.getElementById('acc-grundeinstellungen-body').classList.add('is-open');

View File

@@ -0,0 +1,217 @@
(function () {
'use strict';
// ── Styles ──────────────────────────────────────────────────────────────
const style = document.createElement('style');
style.textContent = `
.post-hashtag {
color: var(--color-primary);
text-decoration: none;
font-weight: 600;
}
.post-hashtag:hover { text-decoration: underline; }
.hashtag-dropdown {
position: fixed;
z-index: 600;
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 8px;
min-width: 170px;
max-width: 260px;
box-shadow: 0 4px 20px rgba(0,0,0,0.45);
overflow: hidden;
}
.hashtag-dropdown-item {
padding: 0.45rem 0.9rem;
cursor: pointer;
font-size: 0.88rem;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.hashtag-dropdown-item:hover,
.hashtag-dropdown-item.active {
background: var(--color-secondary);
color: var(--color-primary);
}
.hashtag-dropdown-create {
font-style: italic;
border-top: 1px solid var(--color-secondary);
color: var(--color-primary);
}
.hashtag-dropdown-hint {
padding: 0.45rem 0.9rem;
font-size: 0.82rem;
color: var(--color-text);
opacity: 0.6;
font-style: italic;
}
`;
document.head.appendChild(style);
// ── Escape helper (works even if shared.js not yet loaded) ───────────────
function esc(s) {
return String(s ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ── renderTextWithHashtags ────────────────────────────────────────────────
window.renderTextWithHashtags = function (text) {
if (!text) return '';
const PATTERN = /#([\wäöüÄÖÜß]{1,100})/g;
const parts = [];
let lastIndex = 0;
let match;
while ((match = PATTERN.exec(text)) !== null) {
parts.push(esc(text.slice(lastIndex, match.index)));
const tag = match[1].toLowerCase();
parts.push(
`<a href="/community/feed.html?tag=${encodeURIComponent(tag)}" ` +
`class="post-hashtag" onclick="event.stopPropagation()">#${esc(match[1])}</a>`
);
lastIndex = match.index + match[0].length;
}
parts.push(esc(text.slice(lastIndex)));
return parts.join('');
};
// ── attachHashtagAutocomplete ─────────────────────────────────────────────
window.attachHashtagAutocomplete = function (textarea) {
let dropdownEl = null;
let suggestions = [];
let selectedIdx = -1;
let debounce = null;
// Returns { prefix, hashStart } if cursor is inside a #word, else null
function getHashAtCursor() {
const pos = textarea.selectionStart;
const text = textarea.value;
let i = pos - 1;
while (i >= 0 && /[\wäöüÄÖÜß]/.test(text[i])) i--;
if (i >= 0 && text[i] === '#') {
return { prefix: text.slice(i + 1, pos), hashStart: i };
}
if (i === -1 && text.length > 0 && text[0] === '#' && pos > 0) {
return { prefix: text.slice(1, pos), hashStart: 0 };
}
return null;
}
function positionDropdown() {
if (!dropdownEl) return;
const r = textarea.getBoundingClientRect();
dropdownEl.style.top = (r.bottom + window.scrollY + 2) + 'px';
dropdownEl.style.left = (r.left + window.scrollX) + 'px';
}
function highlight() {
if (!dropdownEl) return;
dropdownEl.querySelectorAll('.hashtag-dropdown-item').forEach((el, i) => {
el.classList.toggle('active', i === selectedIdx);
});
}
function removeDropdown() {
dropdownEl?.remove();
dropdownEl = null;
suggestions = [];
selectedIdx = -1;
}
function showDropdown(items, prefix) {
removeDropdown();
const lPrefix = prefix ? prefix.toLowerCase() : '';
const hasCreate = lPrefix.length > 0 && !items.some(t => t === lPrefix);
if (!items.length && !hasCreate) {
if (prefix !== undefined && prefix.length === 0) {
// Nur "#" getippt, noch keine populären Tags
dropdownEl = document.createElement('div');
dropdownEl.className = 'hashtag-dropdown';
const hint = document.createElement('div');
hint.className = 'hashtag-dropdown-hint';
hint.textContent = 'Tippe weiter um einen Hashtag zu erstellen';
dropdownEl.appendChild(hint);
document.body.appendChild(dropdownEl);
positionDropdown();
}
return;
}
suggestions = hasCreate ? [...items, lPrefix] : [...items];
dropdownEl = document.createElement('div');
dropdownEl.className = 'hashtag-dropdown';
suggestions.forEach((tag, i) => {
const isCreate = hasCreate && i === suggestions.length - 1;
const item = document.createElement('div');
item.className = 'hashtag-dropdown-item' + (isCreate ? ' hashtag-dropdown-create' : '');
item.textContent = (isCreate ? '+ #' : '#') + tag;
item.addEventListener('mousedown', e => { e.preventDefault(); insertTag(tag); });
item.addEventListener('mouseover', () => { selectedIdx = i; highlight(); });
dropdownEl.appendChild(item);
});
document.body.appendChild(dropdownEl);
positionDropdown();
}
function insertTag(tag) {
const info = getHashAtCursor();
if (!info) { removeDropdown(); return; }
const v = textarea.value;
const cursor = textarea.selectionStart;
const before = v.slice(0, info.hashStart);
const after = v.slice(cursor);
const inserted = '#' + tag + ' ';
textarea.value = before + inserted + after;
const newPos = before.length + inserted.length;
textarea.setSelectionRange(newPos, newPos);
textarea.focus();
textarea.dispatchEvent(new Event('input', { bubbles: true }));
removeDropdown();
}
textarea.addEventListener('input', () => {
clearTimeout(debounce);
const info = getHashAtCursor();
if (!info) { removeDropdown(); return; }
debounce = setTimeout(async () => {
try {
const url = info.prefix.length === 0
? '/hashtags/popular?limit=6'
: '/hashtags/suggest?q=' + encodeURIComponent(info.prefix) + '&limit=6';
const res = await fetch(url);
if (res.ok) showDropdown(await res.json(), info.prefix);
else showDropdown([], info.prefix);
} catch { removeDropdown(); }
}, 150);
});
textarea.addEventListener('keydown', e => {
if (!dropdownEl) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
selectedIdx = Math.min(selectedIdx + 1, suggestions.length - 1);
highlight();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
selectedIdx = Math.max(selectedIdx - 1, 0);
highlight();
} else if ((e.key === 'Enter' || e.key === 'Tab') && selectedIdx >= 0) {
e.preventDefault();
insertTag(suggestions[selectedIdx]);
} else if (e.key === 'Escape') {
removeDropdown();
}
});
textarea.addEventListener('blur', () => setTimeout(removeDropdown, 150));
window.addEventListener('scroll', positionDropdown, { passive: true });
window.addEventListener('resize', positionDropdown, { passive: true });
};
})();

View File

@@ -19,7 +19,7 @@
box-shadow: 0 2px 12px rgba(0,0,0,0.4); box-shadow: 0 2px 12px rgba(0,0,0,0.4);
z-index: 500; z-index: 500;
align-items: center; align-items: center;
padding: 0 0.25rem; padding: 0 4px 0 0.25rem;
} }
.mobile-topbar-logo { .mobile-topbar-logo {
position: absolute; position: absolute;
@@ -43,9 +43,9 @@
background: none; background: none;
border: none; border: none;
color: var(--color-text); color: var(--color-text);
font-size: 1.725rem; font-size: 1.3rem;
line-height: 1; line-height: 1;
padding: 0.75rem 0.675rem; padding: 0.55rem 0.6rem;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -301,7 +301,7 @@
'/community/gruppen.html', '/community/gruppe.html', '/community/gruppen.html', '/community/gruppe.html',
'/community/locations.html', '/community/location-detail.html', '/community/locations.html', '/community/location-detail.html',
'/community/events.html', '/community/event-detail.html', '/community/events.html', '/community/event-detail.html',
'/community/abonnements.html', '/community/personen-suchen.html', '/community/abonnements.html',
'/community/benutzer.html', '/community/benutzer.html',
])} ])}
${column('colDating', 'Dating', col3Html, ['/dating/'])} ${column('colDating', 'Dating', col3Html, ['/dating/'])}

View File

@@ -5,12 +5,19 @@
// ── Bereichs-Definitionen ──────────────────────────────────────────────── // ── Bereichs-Definitionen ────────────────────────────────────────────────
const SECTIONS = { const SECTIONS = {
common: {
prefixes: ['/games/common/'],
items: [
{ href: '/games/vanilla/neuvanilla.html', icon: 'VANILLA', label: 'Vanilla Game' },
{ href: '/games/bdsm/neubdsm.html', icon: 'BDSM', label: 'BDSM Game' },
{ href: '/games/chastity/neulock.html', icon: 'CHASTITY', label: 'Chastity Game' },
],
},
social: { social: {
prefixes: ['/community/'], prefixes: ['/community/'],
exclude: [ exclude: [
'/community/nachrichten.html', '/community/nachrichten.html',
'/community/benachrichtigungen.html', '/community/benachrichtigungen.html',
'/community/einladungen.html',
], ],
items: [ items: [
{ href: '/community/feed.html', icon: 'FEED', label: 'Feed' }, { href: '/community/feed.html', icon: 'FEED', label: 'Feed' },

View File

@@ -168,18 +168,26 @@
async function doSearch(q, overlay) { async function doSearch(q, overlay) {
try { try {
const res = await fetch('/search?q=' + encodeURIComponent(q) + '&limit=3'); const tagQuery = q.startsWith('#') ? q.slice(1) : q;
if (!res.ok) { overlay.innerHTML = '<div class="topbar-search-hint">Fehler bei der Suche.</div>'; return; } const [searchRes, gruppenRes, hashtagRes] = await Promise.all([
const data = await res.json(); fetch('/search?q=' + encodeURIComponent(q) + '&limit=3'),
const { users = [], locations = [], events = [] } = data; fetch('/gruppen/search?q=' + encodeURIComponent(q)),
fetch('/hashtags/suggest?q=' + encodeURIComponent(tagQuery) + '&limit=4')
]);
if (!users.length && !locations.length && !events.length) { const data = searchRes.ok ? await searchRes.json() : {};
overlay.innerHTML = '<div class="topbar-search-hint">Keine Ergebnisse.</div>'; const gruppen = gruppenRes.ok ? await gruppenRes.json() : [];
return; const hashtags = hashtagRes.ok ? await hashtagRes.json() : [];
}
const { users = [], locations = [], events = [] } = data;
const gruppenSlice = gruppen.slice(0, 3);
let html = ''; let html = '';
if (!users.length && !locations.length && !events.length && !gruppenSlice.length && !hashtags.length) {
html += '<div class="topbar-search-hint">Keine Ergebnisse.</div>';
}
if (users.length) { if (users.length) {
html += `<div class="topbar-search-section">Personen</div>`; html += `<div class="topbar-search-section">Personen</div>`;
html += users.map(u => { html += users.map(u => {
@@ -213,6 +221,26 @@
}).join(''); }).join('');
} }
if (gruppenSlice.length) {
html += `<div class="topbar-search-section">Gruppen</div>`;
html += gruppenSlice.map(g => {
const av = g.bild
? `<img src="data:image/jpeg;base64,${esc(g.bild)}" class="topbar-search-avatar" alt="">`
: `<span class="topbar-search-avatar topbar-search-avatar--placeholder">👥</span>`;
return `<a href="/community/gruppe.html?gruppeId=${esc(g.gruppeId)}" class="topbar-search-result">
${av}<span>${esc(g.name)}</span></a>`;
}).join('');
}
if (hashtags.length) {
html += `<div class="topbar-search-section">Hashtags</div>`;
html += `<div style="display:flex;flex-wrap:wrap;gap:0.4rem;padding:0.4rem 0.75rem;">`;
html += hashtags.map(tag =>
`<a href="/community/feed.html?tag=${encodeURIComponent(tag)}" style="display:inline-block;padding:0.25rem 0.65rem;background:var(--color-secondary);border-radius:14px;font-size:0.82rem;font-weight:600;color:var(--color-primary);text-decoration:none;">#${esc(tag)}</a>`
).join('');
html += `</div>`;
}
html += `<a href="/search.html?q=${encodeURIComponent(q)}" class="topbar-search-all">Alle Ergebnisse anzeigen →</a>`; html += `<a href="/search.html?q=${encodeURIComponent(q)}" class="topbar-search-all">Alle Ergebnisse anzeigen →</a>`;
overlay.innerHTML = html; overlay.innerHTML = html;
} catch (e) { } catch (e) {

View File

@@ -163,6 +163,33 @@
transition: border-color 0.15s, color 0.15s; transition: border-color 0.15s, color 0.15s;
} }
.search-load-more:hover { border-color: var(--color-primary); color: var(--color-primary); background: none; } .search-load-more:hover { border-color: var(--color-primary); color: var(--color-primary); background: none; }
.hashtag-chip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.4rem 0.85rem;
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 20px;
color: var(--color-primary);
font-weight: 600;
font-size: 0.9rem;
text-decoration: none;
transition: border-color 0.15s, background 0.15s;
}
.hashtag-chip:hover { border-color: var(--color-primary); background: var(--color-secondary); }
/* Dialog (Gruppen-Beitritt) */
.dialog-backdrop { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.6); z-index:200; align-items:center; justify-content:center; }
.dialog-backdrop.visible { display:flex; }
.dialog { background:var(--color-card); border:1px solid var(--color-secondary); border-radius:12px; padding:1.75rem; width:100%; max-width:420px; box-shadow:0 8px 32px rgba(0,0,0,0.6); max-height:90vh; overflow-y:auto; }
.dialog h3 { color:var(--color-primary); font-size:1.1rem; margin-bottom:1.25rem; }
.dialog label { display:block; font-size:0.8rem; color:#aaa; margin-bottom:0.3rem; margin-top:1rem; }
.dialog textarea { width:100%; padding:0.6rem 0.85rem; border:1px solid var(--color-secondary); border-radius:6px; background:var(--color-secondary); color:var(--color-text); font-size:0.95rem; outline:none; transition:border-color 0.2s; resize:vertical; min-height:80px; box-sizing:border-box; }
.dialog textarea:focus { border-color:var(--color-primary); }
.dialog-actions { display:flex; justify-content:flex-end; gap:0.75rem; margin-top:1.5rem; }
.dialog-actions button { flex:none; margin:0; padding:0.55rem 1.1rem; font-size:0.9rem; width:auto; }
</style> </style>
</head> </head>
<body class="app"> <body class="app">
@@ -173,7 +200,7 @@
<div class="search-hero"> <div class="search-hero">
<div class="search-hero-input-wrap"> <div class="search-hero-input-wrap">
<span class="search-hero-icon" id="searchIcon"></span> <span class="search-hero-icon" id="searchIcon"></span>
<input type="text" id="searchInput" placeholder="Suchen nach Personen, Locations, Veranstaltungen…" <input type="text" id="searchInput" placeholder="Suchen nach Personen, Locations, Veranstaltungen, Gruppen…"
autocomplete="off" spellcheck="false"> autocomplete="off" spellcheck="false">
</div> </div>
</div> </div>
@@ -188,6 +215,12 @@
<button class="search-tab-btn" data-tab="events"> <button class="search-tab-btn" data-tab="events">
Veranstaltungen <span class="search-tab-count" id="countEvents">0</span> Veranstaltungen <span class="search-tab-count" id="countEvents">0</span>
</button> </button>
<button class="search-tab-btn" data-tab="gruppen">
Gruppen <span class="search-tab-count" id="countGruppen">0</span>
</button>
<button class="search-tab-btn" data-tab="hashtags">
Hashtags <span class="search-tab-count" id="countHashtags">0</span>
</button>
</div> </div>
<div class="search-panel active" id="panel-users"> <div class="search-panel active" id="panel-users">
@@ -207,6 +240,31 @@
<div class="search-grid" id="gridEvents"></div> <div class="search-grid" id="gridEvents"></div>
<button class="search-load-more" id="moreEvents" style="display:none;">Mehr laden</button> <button class="search-load-more" id="moreEvents" style="display:none;">Mehr laden</button>
</div> </div>
<div class="search-panel" id="panel-gruppen">
<div class="search-loading" id="loadingGruppen" style="display:none;">Wird geladen…</div>
<div class="search-grid" id="gridGruppen"></div>
</div>
<div class="search-panel" id="panel-hashtags">
<div class="search-loading" id="loadingHashtags" style="display:none;">Wird geladen…</div>
<div id="gridHashtags" style="display:flex; flex-wrap:wrap; gap:0.6rem; padding-top:0.25rem;"></div>
</div>
</div>
</div>
<!-- Beitrittsanfrage Dialog -->
<div class="dialog-backdrop" id="searchJoinDialog">
<div class="dialog">
<h3>Beitrittsanfrage senden</h3>
<p id="searchJoinGroupName" style="font-weight:600; margin-bottom:0.5rem;"></p>
<label>Nachricht (optional)</label>
<textarea id="searchJoinNachricht" placeholder="Warum möchtest du beitreten?"></textarea>
<p class="message error" id="searchJoinError" style="display:none; margin-top:0.75rem;"></p>
<div class="dialog-actions">
<button class="secondary" id="searchJoinCancelBtn">Abbrechen</button>
<button id="searchJoinSendBtn">Anfrage senden</button>
</div>
</div> </div>
</div> </div>
@@ -279,10 +337,12 @@
document.getElementById('grid' + cap(t)).innerHTML = ''; document.getElementById('grid' + cap(t)).innerHTML = '';
document.getElementById('more' + cap(t)).style.display = 'none'; document.getElementById('more' + cap(t)).style.display = 'none';
}); });
// Alle drei Typen parallel laden // Alle Typen parallel laden
loadChunk('users'); loadChunk('users');
loadChunk('locations'); loadChunk('locations');
loadChunk('events'); loadChunk('events');
loadGruppen(q);
loadHashtags(q);
} }
function clearAll() { function clearAll() {
@@ -295,6 +355,12 @@
document.getElementById('count' + cap(t)).textContent = '0'; document.getElementById('count' + cap(t)).textContent = '0';
document.getElementById('loading' + cap(t)).style.display = 'none'; document.getElementById('loading' + cap(t)).style.display = 'none';
}); });
document.getElementById('gridGruppen').innerHTML = '';
document.getElementById('countGruppen').textContent = '0';
document.getElementById('loadingGruppen').style.display = 'none';
document.getElementById('gridHashtags').innerHTML = '';
document.getElementById('countHashtags').textContent = '0';
document.getElementById('loadingHashtags').style.display = 'none';
const url = new URL(window.location); const url = new URL(window.location);
url.searchParams.delete('q'); url.searchParams.delete('q');
history.replaceState(null, '', url); history.replaceState(null, '', url);
@@ -374,14 +440,185 @@
}); });
} }
// ── Gruppen-Suche ──
async function loadGruppen(q) {
const loadingEl = document.getElementById('loadingGruppen');
const grid = document.getElementById('gridGruppen');
loadingEl.style.display = '';
grid.innerHTML = '';
try {
const res = await fetch('/gruppen/search?q=' + encodeURIComponent(q));
if (!res.ok) throw new Error();
const data = await res.json();
document.getElementById('countGruppen').textContent = data.length;
if (!data.length) {
grid.innerHTML = '<div class="search-empty" style="grid-column:1/-1;">Keine Ergebnisse.</div>';
return;
}
data.forEach(g => grid.appendChild(buildGruppeCard(g)));
} catch (e) {
// ignore
} finally {
loadingEl.style.display = 'none';
}
}
function buildGruppeCard(g) {
const card = document.createElement('div');
card.className = 'search-card';
card.style.cssText = 'justify-content:space-between; cursor:pointer;';
card.addEventListener('click', () => { location.href = '/community/gruppe.html?gruppeId=' + g.gruppeId; });
const av = g.bild
? `<div class="search-card-avatar search-card-avatar--square"><img src="data:image/jpeg;base64,${esc(g.bild)}" alt=""></div>`
: `<div class="search-card-avatar search-card-avatar--square">👥</div>`;
const privBadge = g.isPrivate ? ' 🔒' : '';
const sub = g.memberCount + ' Mitglied' + (g.memberCount !== 1 ? 'er' : '');
const info = document.createElement('div');
info.style.cssText = 'display:flex; align-items:center; gap:0.75rem; min-width:0; flex:1;';
info.innerHTML = `${av}<div class="search-card-body"><div class="search-card-name">${esc(g.name)}${privBadge}</div><div class="search-card-sub">${esc(sub)}</div></div>`;
card.appendChild(info);
if (!g.myRole) {
const btn = document.createElement('button');
btn.style.cssText = 'font-size:0.78rem; padding:0.3rem 0.65rem; width:auto; margin:0; white-space:nowrap; flex-shrink:0; margin-left:0.5rem;';
if (g.myRequestStatus === 'AUSSTEHEND') {
btn.disabled = true;
btn.style.opacity = '0.6';
btn.textContent = 'Anfrage ausstehend';
} else if (g.isPrivate) {
btn.textContent = 'Anfrage senden';
btn.addEventListener('click', e => { e.stopPropagation(); openSearchJoinDialog(g.gruppeId, g.name); });
} else {
btn.textContent = 'Beitreten';
btn.addEventListener('click', e => { e.stopPropagation(); joinGruppeSearch(g.gruppeId, btn); });
}
card.appendChild(btn);
}
return card;
}
// ── Hashtag-Suche ──
async function loadHashtags(q) {
const loadingEl = document.getElementById('loadingHashtags');
const grid = document.getElementById('gridHashtags');
loadingEl.style.display = '';
grid.innerHTML = '';
try {
const raw = q.startsWith('#') ? q.slice(1) : q;
const res = await fetch('/hashtags/suggest?q=' + encodeURIComponent(raw) + '&limit=20');
if (!res.ok) throw new Error();
const tags = await res.json();
document.getElementById('countHashtags').textContent = tags.length;
if (!tags.length) {
grid.innerHTML = '<div class="search-empty">Keine Hashtags gefunden.</div>';
return;
}
tags.forEach(tag => {
const a = document.createElement('a');
a.className = 'hashtag-chip';
a.href = '/community/feed.html?tag=' + encodeURIComponent(tag);
a.textContent = '#' + tag;
grid.appendChild(a);
});
} catch (e) {
// ignore
} finally {
loadingEl.style.display = 'none';
}
}
async function joinGruppeSearch(gruppeId, btn) {
btn.disabled = true;
btn.textContent = '…';
try {
const res = await fetch('/gruppen/' + gruppeId + '/join', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}'
});
if (res.ok || res.status === 201) {
btn.textContent = 'Beigetreten ✓';
} else {
btn.disabled = false;
btn.textContent = 'Beitreten';
}
} catch (e) {
btn.disabled = false;
btn.textContent = 'Beitreten';
}
}
// ── Join-Dialog für Gruppen ──
let searchJoinGruppeId = null;
function openSearchJoinDialog(gruppeId, name) {
searchJoinGruppeId = gruppeId;
document.getElementById('searchJoinGroupName').textContent = name;
document.getElementById('searchJoinNachricht').value = '';
document.getElementById('searchJoinError').style.display = 'none';
document.getElementById('searchJoinDialog').classList.add('visible');
}
function closeSearchJoinDialog() {
document.getElementById('searchJoinDialog').classList.remove('visible');
searchJoinGruppeId = null;
}
async function sendSearchJoinRequest() {
if (!searchJoinGruppeId) return;
document.getElementById('searchJoinError').style.display = 'none';
const nachricht = document.getElementById('searchJoinNachricht').value.trim() || null;
try {
const res = await fetch('/gruppen/' + searchJoinGruppeId + '/join', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nachricht })
});
if (res.ok || res.status === 201) {
closeSearchJoinDialog();
if (currentQuery.length >= 2) loadGruppen(currentQuery);
} else {
const el = document.getElementById('searchJoinError');
el.textContent = 'Fehler beim Senden der Anfrage.';
el.style.display = 'block';
}
} catch (e) {
const el = document.getElementById('searchJoinError');
el.textContent = 'Fehler: ' + e.message;
el.style.display = 'block';
}
}
document.getElementById('searchJoinCancelBtn').addEventListener('click', closeSearchJoinDialog);
document.getElementById('searchJoinSendBtn').addEventListener('click', sendSearchJoinRequest);
document.getElementById('searchJoinDialog').addEventListener('click', e => {
if (e.target === document.getElementById('searchJoinDialog')) closeSearchJoinDialog();
});
function cap(s) { return s.charAt(0).toUpperCase() + s.slice(1); } function cap(s) { return s.charAt(0).toUpperCase() + s.slice(1); }
// ── URL-Parameter beim Start auslesen ── // ── URL-Parameter beim Start auslesen ──
const initQ = new URLSearchParams(window.location.search).get('q') || ''; const params = new URLSearchParams(window.location.search);
const initQ = params.get('q') || '';
const initTab = params.get('tab') || '';
if (initTab) {
const tabBtn = document.querySelector(`.search-tab-btn[data-tab="${initTab}"]`);
if (tabBtn) tabBtn.click();
}
if (initQ.length >= 2) { if (initQ.length >= 2) {
input.value = initQ; input.value = initQ;
// Warten bis icons.js geladen ist
setTimeout(() => startSearch(initQ), 100); setTimeout(() => startSearch(initQ), 100);
} else if (initTab === 'hashtags') {
// Populäre Tags zeigen wenn kein Suchbegriff
setTimeout(() => loadHashtags(''), 100);
} }
})(); })();
</script> </script>

View File

@@ -243,6 +243,33 @@
.lb-comment-compose button { width:auto; margin:0; padding:0.35rem 0.75rem; font-size:0.8rem; } .lb-comment-compose button { width:auto; margin:0; padding:0.35rem 0.75rem; font-size:0.8rem; }
@media (max-width:650px) { .lb-layout { flex-direction:column; height:95vh; } .lb-post-side { border-right:none; border-bottom:1px solid var(--color-secondary); max-height:55vh; } .lb-comments-panel { width:100%; } } @media (max-width:650px) { .lb-layout { flex-direction:column; height:95vh; } .lb-post-side { border-right:none; border-bottom:1px solid var(--color-secondary); max-height:55vh; } .lb-comments-panel { width:100%; } }
/* ── Spiel starten ── */
.start-game-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 0.75rem;
}
.start-game-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.6rem;
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 12px;
padding: 1.25rem 1rem;
text-decoration: none;
color: var(--color-text);
transition: border-color 0.15s, background 0.15s;
text-align: center;
}
.start-game-card:hover {
border-color: var(--color-primary);
background: rgba(var(--color-primary-rgb,233,69,96),0.06);
}
.start-game-icon { font-size: 2rem; line-height: 1; }
.start-game-title { font-size: 0.9rem; font-weight: 600; }
/* ── Neue Mitglieder ── */ /* ── Neue Mitglieder ── */
.new-members-strip { .new-members-strip {
display: flex; display: flex;
@@ -306,6 +333,25 @@
<div class="active-game-list" id="activeLockList"></div> <div class="active-game-list" id="activeLockList"></div>
</div> </div>
<!-- Kein Spiel aktiv Starten -->
<div id="startGameSection" style="display:none;">
<div class="section-label">Spiel starten 🎮</div>
<div class="start-game-grid">
<a href="/games/vanilla/neuvanilla.html" class="start-game-card">
<div class="start-game-icon">🎭</div>
<div class="start-game-title">Vanilla Game starten</div>
</a>
<a href="/games/bdsm/neubdsm.html" class="start-game-card">
<div class="start-game-icon"></div>
<div class="start-game-title">BDSM-Game starten</div>
</a>
<a href="/games/chastity/neulock.html" class="start-game-card">
<div class="start-game-icon">🔒</div>
<div class="start-game-title">Chastity-Lock starten</div>
</a>
</div>
</div>
<!-- Einladungen --> <!-- Einladungen -->
<div id="invitesSection" style="display:none;"> <div id="invitesSection" style="display:none;">
<div class="section-label">Einladungen 📨</div> <div class="section-label">Einladungen 📨</div>
@@ -423,6 +469,7 @@
<script src="/js/shared.js"></script> <script src="/js/shared.js"></script>
<script src="/js/icons.js"></script> <script src="/js/icons.js"></script>
<script src="/js/hashtag.js"></script>
<script src="/js/nav.js"></script> <script src="/js/nav.js"></script>
<script> <script>
let myUserId = null; let myUserId = null;
@@ -436,14 +483,20 @@
if (user) { if (user) {
myUserId = user.userId; myUserId = user.userId;
document.getElementById('greeting').textContent = 'Willkommen zurück, ' + user.name + '!'; document.getElementById('greeting').textContent = 'Willkommen zurück, ' + user.name + '!';
loadActiveGames(user.userId); Promise.all([loadActiveGames(user.userId), loadActiveLock()]).then(() => {
loadActiveLock(); const hasGames = document.getElementById('activeGamesSection').style.display !== 'none';
const hasLock = document.getElementById('activeLockSection').style.display !== 'none';
if (!hasGames && !hasLock) {
document.getElementById('startGameSection').style.display = '';
}
});
loadInvites(); loadInvites();
loadFriendRequests(); loadFriendRequests();
loadVisitors(); loadVisitors();
loadMyEvents(); loadMyEvents();
loadLocEvents(); loadLocEvents();
loadFeed(); loadFeed();
attachHashtagAutocomplete(document.getElementById('homeComposeText'));
if (user.datingAktiv) { if (user.datingAktiv) {
loadWhoLikesMe(); loadWhoLikesMe();
loadMatches(); loadMatches();
@@ -931,7 +984,7 @@
<div class="post-meta">${fmtDate(p.createdAt)}${groupBadge}</div> <div class="post-meta">${fmtDate(p.createdAt)}${groupBadge}</div>
</div> </div>
</div> </div>
<div class="post-text">${esc(p.text || '')}</div> <div class="post-text">${renderTextWithHashtags(p.text || '')}</div>
${bildHtml} ${bildHtml}
${umfrageHtml} ${umfrageHtml}
<div class="post-actions"> <div class="post-actions">

View File

@@ -9,7 +9,7 @@ services:
MYSQL_USER: ${DB_USER} MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${DB_PASSWORD} MYSQL_PASSWORD: ${DB_PASSWORD}
ports: ports:
- "127.0.0.1:3306:3306" - "3306:3306"
volumes: volumes:
# Format: [Pfad auf dem Proxmox-Host]:[Pfad im Container] # Format: [Pfad auf dem Proxmox-Host]:[Pfad im Container]
- /mnt/pve_nas/.mysql_data:/var/lib/mysql - /mnt/pve_nas/.mysql_data:/var/lib/mysql

View File

@@ -54,7 +54,6 @@ public class SecurityConfig {
.requestMatchers("/sessionbdsmingame.html").authenticated() .requestMatchers("/sessionbdsmingame.html").authenticated()
.requestMatchers("/games/bdsm/neubdsm.html").authenticated() .requestMatchers("/games/bdsm/neubdsm.html").authenticated()
.requestMatchers("/games/bdsm/bdsmingame.html").authenticated() .requestMatchers("/games/bdsm/bdsmingame.html").authenticated()
.requestMatchers("/community/personen-suchen.html").authenticated()
.requestMatchers("/community/freunde.html").authenticated() .requestMatchers("/community/freunde.html").authenticated()
.requestMatchers("/community/nachrichten.html").authenticated() .requestMatchers("/community/nachrichten.html").authenticated()
.requestMatchers("/community/benutzer.html").authenticated() .requestMatchers("/community/benutzer.html").authenticated()
@@ -82,6 +81,7 @@ public class SecurityConfig {
.requestMatchers("/community/event-detail.html").authenticated() .requestMatchers("/community/event-detail.html").authenticated()
.requestMatchers("/gruppen/**").authenticated() .requestMatchers("/gruppen/**").authenticated()
.requestMatchers("/feed/**").authenticated() .requestMatchers("/feed/**").authenticated()
.requestMatchers("/hashtags/**").authenticated()
.requestMatchers("/notifications/**").authenticated() .requestMatchers("/notifications/**").authenticated()
.requestMatchers("/events/**").authenticated() .requestMatchers("/events/**").authenticated()
.requestMatchers("/*.html").permitAll() .requestMatchers("/*.html").permitAll()

View File

@@ -5,7 +5,9 @@ import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.stream.Collectors;
import java.util.stream.Stream; import java.util.stream.Stream;
import org.slf4j.Logger; import org.slf4j.Logger;
@@ -25,6 +27,8 @@ import org.springframework.web.bind.annotation.RestController;
import de.oaa.xxx.feed.dto.FeedItemDto; import de.oaa.xxx.feed.dto.FeedItemDto;
import de.oaa.xxx.feed.dto.FeedPostRequest; import de.oaa.xxx.feed.dto.FeedPostRequest;
import de.oaa.xxx.feed.entity.FeedPostEntity; import de.oaa.xxx.feed.entity.FeedPostEntity;
import de.oaa.xxx.hashtag.HashtagService;
import de.oaa.xxx.hashtag.PostHashtagEntity;
import de.oaa.xxx.feed.entity.FeedPostOptionEntity; import de.oaa.xxx.feed.entity.FeedPostOptionEntity;
import de.oaa.xxx.feed.entity.FeedPostVoteEntity; import de.oaa.xxx.feed.entity.FeedPostVoteEntity;
import de.oaa.xxx.feed.repository.FeedPostLikeRepository; import de.oaa.xxx.feed.repository.FeedPostLikeRepository;
@@ -33,6 +37,7 @@ import de.oaa.xxx.feed.repository.FeedPostRepository;
import de.oaa.xxx.feed.repository.FeedPostVoteRepository; import de.oaa.xxx.feed.repository.FeedPostVoteRepository;
import de.oaa.xxx.gruppe.BeitragTyp; import de.oaa.xxx.gruppe.BeitragTyp;
import de.oaa.xxx.gruppe.dto.UmfrageOptionDto; import de.oaa.xxx.gruppe.dto.UmfrageOptionDto;
import de.oaa.xxx.gruppe.entity.GruppeEntity;
import de.oaa.xxx.gruppe.entity.GruppenbeitragEntity; import de.oaa.xxx.gruppe.entity.GruppenbeitragEntity;
import de.oaa.xxx.gruppe.entity.UmfrageStimmeEntity; import de.oaa.xxx.gruppe.entity.UmfrageStimmeEntity;
import de.oaa.xxx.gruppe.repository.GruppeRepository; import de.oaa.xxx.gruppe.repository.GruppeRepository;
@@ -70,6 +75,7 @@ public class FeedController {
private final UserRepository userRepository; private final UserRepository userRepository;
private final UserService userService; private final UserService userService;
private final LikeService likeService; private final LikeService likeService;
private final HashtagService hashtagService;
public FeedController(FeedPostRepository feedPostRepository, public FeedController(FeedPostRepository feedPostRepository,
FeedPostLikeRepository feedPostLikeRepository, FeedPostLikeRepository feedPostLikeRepository,
@@ -85,7 +91,8 @@ public class FeedController {
KommentarRepository kommentarRepository, KommentarRepository kommentarRepository,
UserRepository userRepository, UserRepository userRepository,
UserService userService, UserService userService,
LikeService likeService) { LikeService likeService,
HashtagService hashtagService) {
this.feedPostRepository = feedPostRepository; this.feedPostRepository = feedPostRepository;
this.feedPostLikeRepository = feedPostLikeRepository; this.feedPostLikeRepository = feedPostLikeRepository;
this.feedPostOptionRepository = feedPostOptionRepository; this.feedPostOptionRepository = feedPostOptionRepository;
@@ -101,6 +108,7 @@ public class FeedController {
this.userRepository = userRepository; this.userRepository = userRepository;
this.userService = userService; this.userService = userService;
this.likeService = likeService; this.likeService = likeService;
this.hashtagService = hashtagService;
} }
record FeedPage(List<FeedItemDto> posts, boolean hasMore) {} record FeedPage(List<FeedItemDto> posts, boolean hasMore) {}
@@ -131,6 +139,7 @@ public class FeedController {
post.setPublic(req.isPublic()); post.setPublic(req.isPublic());
post.setCreatedAt(LocalDateTime.now()); post.setCreatedAt(LocalDateTime.now());
feedPostRepository.save(post); feedPostRepository.save(post);
hashtagService.saveForPost(post.getText(), "FEED", post.getPostId(), post.getCreatedAt());
LOGGER.info("User {} hat Feed-Post {} erstellt (Typ: {}, public: {})", myId, post.getPostId(), typ, post.isPublic()); LOGGER.info("User {} hat Feed-Post {} erstellt (Typ: {}, public: {})", myId, post.getPostId(), typ, post.isPublic());
if (typ == BeitragTyp.UMFRAGE && req.optionen() != null) { if (typ == BeitragTyp.UMFRAGE && req.optionen() != null) {
@@ -251,6 +260,55 @@ public class FeedController {
return ResponseEntity.ok(new FeedPage(items, !nextPage.isEmpty())); return ResponseEntity.ok(new FeedPage(items, !nextPage.isEmpty()));
} }
// ── GET /feed/hashtag?tag= ──
@GetMapping("/hashtag")
public ResponseEntity<FeedPage> getHashtagFeed(@RequestParam String tag,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
Principal principal) {
UUID myId = resolveMyId(principal);
if (myId == null) return ResponseEntity.status(401).build();
List<PostHashtagEntity> refs = hashtagService.getPostRefs(tag);
if (refs.isEmpty()) return ResponseEntity.ok(new FeedPage(List.of(), false));
Set<UUID> myGroupIds = mitgliedRepository.findByUserId(myId).stream()
.map(m -> m.getGruppeId())
.collect(Collectors.toSet());
Set<UUID> friendIds = friendshipRepository
.findFriends(myId, FriendshipEntity.Status.ACCEPTED)
.stream()
.map(f -> f.getSenderId().equals(myId) ? f.getReceiverId() : f.getSenderId())
.collect(Collectors.toSet());
List<FeedItemDto> all = new ArrayList<>();
for (PostHashtagEntity ref : refs) {
if ("FEED".equals(ref.getPostType())) {
feedPostRepository.findById(ref.getPostId()).ifPresent(p -> {
boolean visible = p.isPublic()
|| p.getAuthorId().equals(myId)
|| friendIds.contains(p.getAuthorId());
if (visible) all.add(toFeedItemDtoFromPost(p, myId));
});
} else if ("GROUP".equals(ref.getPostType())) {
gruppenbeitragRepository.findById(ref.getPostId()).ifPresent(b -> {
GruppeEntity gruppe = gruppeRepository.findById(b.getGruppeId()).orElse(null);
if (gruppe != null && (!gruppe.isPrivate() || myGroupIds.contains(gruppe.getGruppeId()))) {
all.add(toFeedItemDtoFromGruppe(b, myId));
}
});
}
}
all.sort(Comparator.comparing(FeedItemDto::createdAt).reversed());
int from = page * size;
int to = Math.min(from + size, all.size());
List<FeedItemDto> items = from < all.size() ? all.subList(from, to) : List.of();
return ResponseEntity.ok(new FeedPage(items, to < all.size()));
}
// ── POST /feed/posts/{id}/like ── // ── POST /feed/posts/{id}/like ──
@PostMapping("/posts/{id}/like") @PostMapping("/posts/{id}/like")
@@ -316,6 +374,7 @@ public class FeedController {
if (!post.getAuthorId().equals(myId)) return ResponseEntity.status(403).build(); if (!post.getAuthorId().equals(myId)) return ResponseEntity.status(403).build();
hashtagService.deleteForPost("FEED", id);
feedPostVoteRepository.deleteByPostId(id); feedPostVoteRepository.deleteByPostId(id);
feedPostOptionRepository.deleteByPostId(id); feedPostOptionRepository.deleteByPostId(id);
feedPostLikeRepository.deleteByPostId(id); feedPostLikeRepository.deleteByPostId(id);

View File

@@ -3,6 +3,7 @@ package de.oaa.xxx.gruppe;
import de.oaa.xxx.gruppe.dto.*; import de.oaa.xxx.gruppe.dto.*;
import de.oaa.xxx.gruppe.entity.*; import de.oaa.xxx.gruppe.entity.*;
import de.oaa.xxx.gruppe.repository.*; import de.oaa.xxx.gruppe.repository.*;
import de.oaa.xxx.hashtag.HashtagService;
import de.oaa.xxx.social.LikeService; import de.oaa.xxx.social.LikeService;
import de.oaa.xxx.social.repository.KommentarRepository; import de.oaa.xxx.social.repository.KommentarRepository;
import de.oaa.xxx.user.UserEntity; import de.oaa.xxx.user.UserEntity;
@@ -35,6 +36,7 @@ public class GruppenbeitragController {
private final UserRepository userRepository; private final UserRepository userRepository;
private final UserService userService; private final UserService userService;
private final LikeService likeService; private final LikeService likeService;
private final HashtagService hashtagService;
public GruppenbeitragController(GruppeRepository gruppeRepository, public GruppenbeitragController(GruppeRepository gruppeRepository,
GruppenmitgliedRepository mitgliedRepository, GruppenmitgliedRepository mitgliedRepository,
@@ -46,7 +48,8 @@ public class GruppenbeitragController {
KommentarRepository kommentarRepository, KommentarRepository kommentarRepository,
UserRepository userRepository, UserRepository userRepository,
UserService userService, UserService userService,
LikeService likeService) { LikeService likeService,
HashtagService hashtagService) {
this.gruppeRepository = gruppeRepository; this.gruppeRepository = gruppeRepository;
this.mitgliedRepository = mitgliedRepository; this.mitgliedRepository = mitgliedRepository;
this.beitragRepository = beitragRepository; this.beitragRepository = beitragRepository;
@@ -58,6 +61,7 @@ public class GruppenbeitragController {
this.userRepository = userRepository; this.userRepository = userRepository;
this.userService = userService; this.userService = userService;
this.likeService = likeService; this.likeService = likeService;
this.hashtagService = hashtagService;
} }
record CreateBeitragRequest(String beitragTyp, String text, Boolean multiChoice, List<String> optionen, List<String> bilder) {} record CreateBeitragRequest(String beitragTyp, String text, Boolean multiChoice, List<String> optionen, List<String> bilder) {}
@@ -130,6 +134,7 @@ public class GruppenbeitragController {
beitrag.setBilder(req.bilder() != null ? req.bilder() : List.of()); beitrag.setBilder(req.bilder() != null ? req.bilder() : List.of());
beitrag.setCreatedAt(LocalDateTime.now()); beitrag.setCreatedAt(LocalDateTime.now());
beitragRepository.save(beitrag); beitragRepository.save(beitrag);
hashtagService.saveForPost(beitrag.getText(), "GROUP", beitrag.getBeitragId(), beitrag.getCreatedAt());
LOGGER.debug("User {} hat Beitrag {} (Typ: {}) in Gruppe {} erstellt", myId, beitrag.getBeitragId(), typ, id); LOGGER.debug("User {} hat Beitrag {} (Typ: {}) in Gruppe {} erstellt", myId, beitrag.getBeitragId(), typ, id);
if (typ == BeitragTyp.UMFRAGE && req.optionen() != null) { if (typ == BeitragTyp.UMFRAGE && req.optionen() != null) {
@@ -311,6 +316,7 @@ public class GruppenbeitragController {
private void deleteBeitragCascade(GruppenbeitragEntity beitrag) { private void deleteBeitragCascade(GruppenbeitragEntity beitrag) {
UUID bid = beitrag.getBeitragId(); UUID bid = beitrag.getBeitragId();
hashtagService.deleteForPost("GROUP", bid);
meldungRepository.deleteByBeitragId(bid); meldungRepository.deleteByBeitragId(bid);
stimmeRepository.deleteByBeitragId(bid); stimmeRepository.deleteByBeitragId(bid);
optionRepository.deleteByBeitragId(bid); optionRepository.deleteByBeitragId(bid);

View File

@@ -0,0 +1,35 @@
package de.oaa.xxx.hashtag;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/hashtags")
public class HashtagController {
private final HashtagService hashtagService;
public HashtagController(HashtagService hashtagService) {
this.hashtagService = hashtagService;
}
/** Autocomplete: ?q=ki → ["kinky","kink",...]. Empty q returns popular. */
@GetMapping("/suggest")
public ResponseEntity<List<String>> suggest(
@RequestParam(defaultValue = "") String q,
@RequestParam(defaultValue = "6") int limit) {
return ResponseEntity.ok(hashtagService.suggest(q.stripLeading().replaceFirst("^#", ""), limit));
}
/** Popular hashtags of the last 30 days. */
@GetMapping("/popular")
public ResponseEntity<List<String>> popular(
@RequestParam(defaultValue = "10") int limit) {
return ResponseEntity.ok(hashtagService.getPopular(limit));
}
}

View File

@@ -0,0 +1,21 @@
package de.oaa.xxx.hashtag;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.util.UUID;
@Getter
@Setter
@Entity
@Table(name = "hashtag")
public class HashtagEntity {
@Id
@Column
private UUID hashtagId;
@Column(nullable = false, unique = true, length = 100)
private String name; // lowercase, without #
}

View File

@@ -0,0 +1,15 @@
package de.oaa.xxx.hashtag;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface HashtagRepository extends JpaRepository<HashtagEntity, UUID> {
Optional<HashtagEntity> findByName(String name);
List<HashtagEntity> findByNameStartingWithOrderByNameAsc(String prefix, Pageable pageable);
}

View File

@@ -0,0 +1,100 @@
package de.oaa.xxx.hashtag;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Service
public class HashtagService {
private static final Pattern HASHTAG_PATTERN =
Pattern.compile("#([\\wäöüÄÖÜß]{1,100})");
private final HashtagRepository hashtagRepository;
private final PostHashtagRepository postHashtagRepository;
public HashtagService(HashtagRepository hashtagRepository,
PostHashtagRepository postHashtagRepository) {
this.hashtagRepository = hashtagRepository;
this.postHashtagRepository = postHashtagRepository;
}
/** Extract all unique hashtag names (lowercase, no #) from text. */
public List<String> extractTags(String text) {
if (text == null || text.isBlank()) return List.of();
Matcher m = HASHTAG_PATTERN.matcher(text);
return m.results()
.map(r -> r.group(1).toLowerCase())
.distinct()
.toList();
}
/** Save hashtag relationships for a post. Idempotent per tag. */
@Transactional
public void saveForPost(String text, String postType, UUID postId, LocalDateTime createdAt) {
for (String name : extractTags(text)) {
HashtagEntity ht = hashtagRepository.findByName(name).orElseGet(() -> {
HashtagEntity h = new HashtagEntity();
h.setHashtagId(UUID.randomUUID());
h.setName(name);
return hashtagRepository.save(h);
});
PostHashtagEntity ph = new PostHashtagEntity();
ph.setId(UUID.randomUUID());
ph.setHashtagId(ht.getHashtagId());
ph.setPostType(postType);
ph.setPostId(postId);
ph.setCreatedAt(createdAt);
postHashtagRepository.save(ph);
}
}
/** Remove all hashtag relationships for a deleted post. */
@Transactional
public void deleteForPost(String postType, UUID postId) {
postHashtagRepository.deleteByPostTypeAndPostId(postType, postId);
}
/** Autocomplete: names starting with prefix, ordered alphabetically. */
public List<String> suggest(String prefix, int limit) {
String normalized = normalize(prefix);
if (normalized.isEmpty()) return getPopular(limit);
return hashtagRepository
.findByNameStartingWithOrderByNameAsc(normalized, PageRequest.of(0, limit))
.stream()
.map(HashtagEntity::getName)
.toList();
}
/** Most-used tags over the last 30 days. */
public List<String> getPopular(int limit) {
LocalDateTime since = LocalDateTime.now().minusDays(30);
return postHashtagRepository
.findPopularSince(since, PageRequest.of(0, limit))
.stream()
.map(row -> hashtagRepository.findById(row.getHashtagId())
.map(HashtagEntity::getName).orElse(null))
.filter(Objects::nonNull)
.toList();
}
/** All post references for a given hashtag name (normalised). */
public List<PostHashtagEntity> getPostRefs(String name) {
String normalized = normalize(name);
return hashtagRepository.findByName(normalized)
.map(ht -> postHashtagRepository.findByHashtagId(ht.getHashtagId()))
.orElse(List.of());
}
private static String normalize(String raw) {
if (raw == null) return "";
return raw.replaceAll("[^\\wäöüÄÖÜß]", "").toLowerCase();
}
}

View File

@@ -0,0 +1,35 @@
package de.oaa.xxx.hashtag;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.UUID;
@Getter
@Setter
@Entity
@Table(name = "post_hashtag", indexes = {
@Index(name = "idx_ph_hashtag", columnList = "hashtagId"),
@Index(name = "idx_ph_post", columnList = "postType, postId")
})
public class PostHashtagEntity {
@Id
@Column
private UUID id;
@Column(nullable = false)
private UUID hashtagId;
/** "FEED" or "GROUP" */
@Column(nullable = false, length = 10)
private String postType;
@Column(nullable = false)
private UUID postId;
@Column(nullable = false)
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,29 @@
package de.oaa.xxx.hashtag;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
public interface PostHashtagRepository extends JpaRepository<PostHashtagEntity, UUID> {
List<PostHashtagEntity> findByHashtagId(UUID hashtagId);
void deleteByPostTypeAndPostId(String postType, UUID postId);
interface HashtagFrequency {
UUID getHashtagId();
Long getCnt();
}
@Query("SELECT ph.hashtagId AS hashtagId, COUNT(ph.id) AS cnt " +
"FROM PostHashtagEntity ph " +
"WHERE ph.createdAt >= :since " +
"GROUP BY ph.hashtagId " +
"ORDER BY cnt DESC")
List<HashtagFrequency> findPopularSince(@Param("since") LocalDateTime since, Pageable pageable);
}

View File

@@ -68,6 +68,11 @@
.empty-hint { color:var(--color-muted); font-size:0.9rem; margin-top:0.5rem; } .empty-hint { color:var(--color-muted); font-size:0.9rem; margin-top:0.5rem; }
.sentinel { height:1px; } .sentinel { height:1px; }
.hashtag-banner { display:flex; align-items:center; gap:0.75rem; margin-bottom:1.25rem; padding:0.65rem 1rem; background:var(--color-card); border:1px solid var(--color-secondary); border-radius:8px; }
.hashtag-banner-tag { font-size:1.05rem; font-weight:700; color:var(--color-primary); }
.hashtag-banner-back { margin-left:auto; font-size:0.82rem; color:var(--color-muted); text-decoration:none; }
.hashtag-banner-back:hover { color:var(--color-primary); }
/* Lightbox */ /* Lightbox */
.lightbox { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.88); z-index:300; align-items:center; justify-content:center; } .lightbox { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.88); z-index:300; align-items:center; justify-content:center; }
.lightbox.open { display:flex; } .lightbox.open { display:flex; }
@@ -92,7 +97,13 @@
<div class="main"> <div class="main">
<div class="content"> <div class="content">
<div class="tabs"> <!-- Hashtag-Banner (nur sichtbar wenn ?tag=… gesetzt) -->
<div class="hashtag-banner" id="hashtagBanner" style="display:none;">
<span>Posts mit </span><span class="hashtag-banner-tag" id="hashtagBannerLabel"></span>
<a class="hashtag-banner-back" href="/community/feed.html">× Zurück zum Feed</a>
</div>
<div class="tabs" id="feedTabs">
<button class="tab-btn active" id="tabMine" data-tab="mine" onclick="switchTab('mine', this)">Mein Feed</button> <button class="tab-btn active" id="tabMine" data-tab="mine" onclick="switchTab('mine', this)">Mein Feed</button>
<button class="tab-btn" id="tabPublic" data-tab="public" onclick="switchTab('public', this)">Öffentlicher Feed</button> <button class="tab-btn" id="tabPublic" data-tab="public" onclick="switchTab('public', this)">Öffentlicher Feed</button>
</div> </div>
@@ -140,6 +151,13 @@
<div class="sentinel" id="publicSentinel"></div> <div class="sentinel" id="publicSentinel"></div>
</div> </div>
<!-- Hashtag-Feed (wird angezeigt wenn ?tag=… gesetzt) -->
<div id="tab-hashtag" style="display:none;">
<div id="hashtagFeed"></div>
<p class="empty-hint" id="hashtagEmpty" style="display:none;">Keine Posts mit diesem Hashtag.</p>
<div class="sentinel" id="hashtagSentinel"></div>
</div>
</div> </div>
</div> </div>
@@ -165,37 +183,68 @@
<script src="/js/shared.js"></script> <script src="/js/shared.js"></script>
<script src="/js/icons.js"></script> <script src="/js/icons.js"></script>
<script src="/js/nav.js"></script> <script src="/js/nav.js"></script>
<script src="/js/social-sidebar.js"></script> <script src="/js/social-sidebar.js"></script>
<script src="/js/meldung.js"></script> <script src="/js/meldung.js"></script>
<script src="/js/hashtag.js"></script>
<script> <script>
// ── State ── // ── State ──
let myUserId = null; let myUserId = null;
let activeLbPostId = null; let activeLbPostId = null;
let activeLbPostType = null; let activeLbPostType = null;
let activeHashtag = null; // set when ?tag=... is in URL
const feedState = { const feedState = {
mine: { page:0, hasMore:true, loading:false, loaded:false }, mine: { page:0, hasMore:true, loading:false, loaded:false },
public: { page:0, hasMore:true, loading:false, loaded:false } public: { page:0, hasMore:true, loading:false, loaded:false },
hashtag: { page:0, hasMore:true, loading:false, loaded:false }
}; };
let composeBilderArr = []; let composeBilderArr = [];
// ── Hashtag-Modus prüfen ──
const _urlTag = new URLSearchParams(window.location.search).get('tag');
if (_urlTag) {
activeHashtag = _urlTag.replace(/^#/, '').toLowerCase();
document.getElementById('hashtagBanner').style.display = '';
document.getElementById('hashtagBannerLabel').textContent = '#' + activeHashtag;
document.getElementById('feedTabs').style.display = 'none';
document.getElementById('tab-mine').style.display = 'none';
document.getElementById('tab-public').style.display = 'none';
document.getElementById('tab-hashtag').style.display = '';
document.getElementById('compose').style.display = 'none';
}
// ── Boot ── // ── Boot ──
fetch('/login/me').then(r => r.ok ? r.json() : null).then(async user => { fetch('/login/me').then(r => r.ok ? r.json() : null).then(async user => {
if (user) { if (user) {
myUserId = user.userId; myUserId = user.userId;
const raw = sessionStorage.getItem('feedOpenPost'); if (activeHashtag) {
if (raw) { await loadFeed('hashtag');
sessionStorage.removeItem('feedOpenPost');
loadFeed('mine');
openLbWithData(JSON.parse(raw));
} else { } else {
await loadFeed('mine'); const raw = sessionStorage.getItem('feedOpenPost');
if (raw) {
sessionStorage.removeItem('feedOpenPost');
loadFeed('mine');
openLbWithData(JSON.parse(raw));
} else {
await loadFeed('mine');
}
} }
} }
}).catch(() => {}); }).catch(() => {});
// ── Autocomplete für Compose ──
document.addEventListener('DOMContentLoaded', () => {
const ta = document.getElementById('composeText');
if (ta) attachHashtagAutocomplete(ta);
});
// Fallback falls DOMContentLoaded bereits gefeuert
if (document.readyState !== 'loading') {
const ta = document.getElementById('composeText');
if (ta) attachHashtagAutocomplete(ta);
}
// ── Tab switching ── // ── Tab switching ──
function switchTab(name, btn) { function switchTab(name, btn) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
@@ -218,8 +267,14 @@
state.loading = true; state.loading = true;
state.loaded = true; state.loaded = true;
try { try {
const endpoint = tab === 'mine' ? '/feed/mine' : '/feed/public'; let url;
const res = await fetch(`${endpoint}?page=${state.page}&size=10`); if (tab === 'hashtag') {
url = `/feed/hashtag?tag=${encodeURIComponent(activeHashtag)}&page=${state.page}&size=10`;
} else {
const base = tab === 'mine' ? '/feed/mine' : '/feed/public';
url = `${base}?page=${state.page}&size=10`;
}
const res = await fetch(url);
if (!res.ok) return; if (!res.ok) return;
const data = await res.json(); const data = await res.json();
const feedEl = document.getElementById(tab + 'Feed'); const feedEl = document.getElementById(tab + 'Feed');
@@ -238,12 +293,14 @@
const observer = new IntersectionObserver(entries => { const observer = new IntersectionObserver(entries => {
entries.forEach(e => { entries.forEach(e => {
if (!e.isIntersecting) return; if (!e.isIntersecting) return;
if (e.target.id === 'mineSentinel') loadFeed('mine'); if (e.target.id === 'mineSentinel') loadFeed('mine');
if (e.target.id === 'publicSentinel') loadFeed('public'); if (e.target.id === 'publicSentinel') loadFeed('public');
if (e.target.id === 'hashtagSentinel') loadFeed('hashtag');
}); });
}, { threshold: 0.5 }); }, { threshold: 0.5 });
observer.observe(document.getElementById('mineSentinel')); observer.observe(document.getElementById('mineSentinel'));
observer.observe(document.getElementById('publicSentinel')); observer.observe(document.getElementById('publicSentinel'));
observer.observe(document.getElementById('hashtagSentinel'));
// bilderCarousel und carNav kommen aus shared.js // bilderCarousel und carNav kommen aus shared.js
@@ -297,7 +354,7 @@
</div> </div>
${deleteBtn} ${deleteBtn}
</div> </div>
<div class="post-text">${esc(p.text)}</div> <div class="post-text">${renderTextWithHashtags(p.text)}</div>
${bildHtml} ${bildHtml}
${umfrageHtml} ${umfrageHtml}
<div class="post-actions"> <div class="post-actions">

View File

@@ -143,7 +143,7 @@
<!-- Friends tab --> <!-- Friends tab -->
<div class="tab-panel active" id="tab-friends"> <div class="tab-panel active" id="tab-friends">
<ul class="user-list" id="friendsList"></ul> <ul class="user-list" id="friendsList"></ul>
<p class="empty-hint" id="friendsEmpty" style="display:none;">Du hast noch keine Freunde. <a href="/community/personen-suchen.html" style="color:var(--color-primary);">Personen suchen</a></p> <p class="empty-hint" id="friendsEmpty" style="display:none;">Du hast noch keine Freunde. <a href="/search.html" style="color:var(--color-primary);">Personen suchen</a></p>
</div> </div>
<!-- Pending tab --> <!-- Pending tab -->

View File

@@ -297,8 +297,9 @@
<script src="/js/shared.js"></script> <script src="/js/shared.js"></script>
<script src="/js/icons.js"></script> <script src="/js/icons.js"></script>
<script src="/js/nav.js"></script> <script src="/js/nav.js"></script>
<script src="/js/social-sidebar.js"></script> <script src="/js/social-sidebar.js"></script>
<script src="/js/hashtag.js"></script>
<script> <script>
// ── Generic modal helpers ── // ── Generic modal helpers ──
@@ -377,6 +378,9 @@
await loadGruppe(); await loadGruppe();
await loadPosts(); await loadPosts();
const _composeText = document.getElementById('composeText');
if (_composeText) attachHashtagAutocomplete(_composeText);
const _savedTab = localStorage.getItem('tab_gruppe_' + gruppeId); const _savedTab = localStorage.getItem('tab_gruppe_' + gruppeId);
if (_savedTab) { if (_savedTab) {
const _btn = document.querySelector(`.tab-btn[data-tab="${_savedTab}"]`); const _btn = document.querySelector(`.tab-btn[data-tab="${_savedTab}"]`);
@@ -488,7 +492,7 @@
}).join(''); }).join('');
body = bars + `<div class="umfrage-total">${total} Stimme${total !== 1 ? 'n' : ''} gesamt${p.multiChoice?' · Multi-Choice':''}</div>`; body = bars + `<div class="umfrage-total">${total} Stimme${total !== 1 ? 'n' : ''} gesamt${p.multiChoice?' · Multi-Choice':''}</div>`;
} else { } else {
body = `<div class="post-text">${esc(p.text)}</div>${bildHtml}`; body = `<div class="post-text">${renderTextWithHashtags(p.text)}</div>${bildHtml}`;
} }
return ` return `
@@ -500,7 +504,7 @@
</div> </div>
<div class="post-date">${fmtDate(p.createdAt)}</div> <div class="post-date">${fmtDate(p.createdAt)}</div>
</div> </div>
${p.beitragTyp === 'UMFRAGE' ? `<div style="font-weight:600;margin-bottom:0.5rem;">${esc(p.text)}</div>${bildHtml}` : ''} ${p.beitragTyp === 'UMFRAGE' ? `<div style="font-weight:600;margin-bottom:0.5rem;">${renderTextWithHashtags(p.text)}</div>${bildHtml}` : ''}
${body} ${body}
<div class="post-actions"> <div class="post-actions">
<button class="post-action-btn ${p.likedByMe?'active':''}" onclick="event.stopPropagation(); toggleLike('${p.beitragId}',this)" id="like-btn-${p.beitragId}"> <button class="post-action-btn ${p.likedByMe?'active':''}" onclick="event.stopPropagation(); toggleLike('${p.beitragId}',this)" id="like-btn-${p.beitragId}">

View File

@@ -22,16 +22,9 @@
.gruppe-card-body { padding:0.85rem; flex:1; display:flex; flex-direction:column; gap:0.4rem; } .gruppe-card-body { padding:0.85rem; flex:1; display:flex; flex-direction:column; gap:0.4rem; }
.gruppe-card-name { font-weight:700; font-size:1rem; } .gruppe-card-name { font-weight:700; font-size:1rem; }
.gruppe-card-meta { font-size:0.78rem; color:var(--color-muted); } .gruppe-card-meta { font-size:0.78rem; color:var(--color-muted); }
.gruppe-card-actions { display:flex; gap:0.5rem; flex-wrap:wrap; margin-top:auto; padding-top:0.5rem; }
.gruppe-card-actions button, .gruppe-card-actions a.btn { margin-top:0; padding:0.3rem 0.7rem; font-size:0.8rem; width:auto; }
.role-badge { font-size:0.7rem; font-weight:700; padding:0.1rem 0.4rem; border-radius:4px; background:var(--color-primary); color:#fff; display:inline-block; } .role-badge { font-size:0.7rem; font-weight:700; padding:0.1rem 0.4rem; border-radius:4px; background:var(--color-primary); color:#fff; display:inline-block; }
.role-badge.mitglied { background:var(--color-secondary); color:var(--color-text); } .role-badge.mitglied { background:var(--color-secondary); color:var(--color-text); }
.search-row { display:flex; gap:0.75rem; margin-bottom:1rem; }
.search-row input { flex:1; }
.search-row button { white-space:nowrap; width:auto; margin-top:0; }
.anfrage-list { list-style:none; margin:0; padding:0; } .anfrage-list { list-style:none; margin:0; padding:0; }
.anfrage-item { display:flex; align-items:center; justify-content:space-between; gap:1rem; padding:0.75rem 0; border-bottom:1px solid var(--color-secondary); } .anfrage-item { display:flex; align-items:center; justify-content:space-between; gap:1rem; padding:0.75rem 0; border-bottom:1px solid var(--color-secondary); }
.anfrage-item:last-child { border-bottom:none; } .anfrage-item:last-child { border-bottom:none; }
@@ -64,12 +57,14 @@
<div class="content"> <div class="content">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:1.25rem;"> <div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:1.25rem;">
<h1 style="margin:0;">Gruppen</h1> <h1 style="margin:0;">Gruppen</h1>
<button onclick="openCreateDialog()" style="width:auto; padding:0.4rem 1rem; font-size:0.9rem; margin:0;">+ Erstellen</button> <div style="display:flex; gap:0.5rem;">
<button onclick="location.href='/search.html?tab=gruppen'" style="width:auto; padding:0.4rem 1rem; font-size:0.9rem; margin:0;">🔍 Suchen</button>
<button onclick="openCreateDialog()" style="width:auto; padding:0.4rem 1rem; font-size:0.9rem; margin:0;">+ Erstellen</button>
</div>
</div> </div>
<div class="tabs"> <div class="tabs">
<button class="tab-btn active" data-tab="mine" onclick="switchTab('mine', this)">Meine Gruppen</button> <button class="tab-btn active" data-tab="mine" onclick="switchTab('mine', this)">Meine Gruppen</button>
<button class="tab-btn" data-tab="discover" onclick="switchTab('discover', this)">Entdecken</button>
<button class="tab-btn" data-tab="requests" onclick="switchTab('requests', this)">Meine Anfragen</button> <button class="tab-btn" data-tab="requests" onclick="switchTab('requests', this)">Meine Anfragen</button>
</div> </div>
@@ -79,16 +74,6 @@
<p class="empty-hint" id="mineEmpty" style="display:none;">Du bist noch in keiner Gruppe.</p> <p class="empty-hint" id="mineEmpty" style="display:none;">Du bist noch in keiner Gruppe.</p>
</div> </div>
<!-- Entdecken -->
<div class="tab-panel" id="tab-discover">
<div class="search-row">
<input type="text" id="searchInput" placeholder="Gruppenname suchen…" onkeydown="if(event.key==='Enter')doSearch()">
<button onclick="doSearch()">Suchen</button>
</div>
<div class="gruppe-grid" id="discoverGrid"></div>
<p class="empty-hint" id="discoverHint">Gib einen Suchbegriff ein.</p>
</div>
<!-- Meine Anfragen --> <!-- Meine Anfragen -->
<div class="tab-panel" id="tab-requests"> <div class="tab-panel" id="tab-requests">
<ul class="anfrage-list" id="requestsList"></ul> <ul class="anfrage-list" id="requestsList"></ul>
@@ -121,20 +106,6 @@
</div> </div>
</div> </div>
<!-- Beitrittsanfrage Dialog -->
<div class="dialog-backdrop" id="joinDialog">
<div class="dialog">
<h3>Beitrittsanfrage senden</h3>
<p id="joinDialogGroupName" style="font-weight:600; margin-bottom:0.5rem;"></p>
<label>Nachricht (optional)</label>
<textarea id="joinNachricht" placeholder="Warum möchtest du beitreten?"></textarea>
<p class="message error" id="joinError" style="display:none; margin-top:0.75rem;"></p>
<div class="dialog-actions">
<button class="secondary" onclick="closeJoinDialog()">Abbrechen</button>
<button onclick="sendJoinRequest()">Anfrage senden</button>
</div>
</div>
</div>
<script src="/js/icons.js"></script> <script src="/js/icons.js"></script>
<script src="/js/nav.js"></script> <script src="/js/nav.js"></script>
@@ -157,7 +128,7 @@
function esc(s) { const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; } function esc(s) { const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; }
function gruppeCard(g, showJoin = false) { function gruppeCard(g) {
const img = g.bild const img = g.bild
? `<div class="gruppe-card-img"><img src="data:image/jpeg;base64,${g.bild}" alt=""></div>` ? `<div class="gruppe-card-img"><img src="data:image/jpeg;base64,${g.bild}" alt=""></div>`
: `<div class="gruppe-card-img">👥</div>`; : `<div class="gruppe-card-img">👥</div>`;
@@ -165,16 +136,6 @@
? `<span class="role-badge ${g.myRole === 'ADMIN' ? '' : 'mitglied'}">${g.myRole === 'ADMIN' ? 'Admin' : 'Mitglied'}</span>` ? `<span class="role-badge ${g.myRole === 'ADMIN' ? '' : 'mitglied'}">${g.myRole === 'ADMIN' ? 'Admin' : 'Mitglied'}</span>`
: ''; : '';
const privBadge = g.isPrivate ? ' 🔒' : ''; const privBadge = g.isPrivate ? ' 🔒' : '';
let actions = '';
if (showJoin && !g.myRole) {
if (g.myRequestStatus === 'AUSSTEHEND') {
actions = `<button disabled style="opacity:0.6;" onclick="event.stopPropagation()">Anfrage ausstehend</button>`;
} else if (g.isPrivate) {
actions = `<button onclick="event.stopPropagation(); openJoinDialog('${g.gruppeId}','${esc(g.name)}')">Anfrage senden</button>`;
} else {
actions = `<button onclick="event.stopPropagation(); joinGruppe('${g.gruppeId}', this)">Beitreten</button>`;
}
}
return ` return `
<div class="gruppe-card" id="gc-${g.gruppeId}" onclick="location.href='/community/gruppe.html?gruppeId=${g.gruppeId}'" style="cursor:pointer;"> <div class="gruppe-card" id="gc-${g.gruppeId}" onclick="location.href='/community/gruppe.html?gruppeId=${g.gruppeId}'" style="cursor:pointer;">
${img} ${img}
@@ -183,7 +144,6 @@
<div class="gruppe-card-meta">${g.memberCount} Mitglied${g.memberCount !== 1 ? 'er' : ''} · ${g.postCount} Beiträge</div> <div class="gruppe-card-meta">${g.memberCount} Mitglied${g.memberCount !== 1 ? 'er' : ''} · ${g.postCount} Beiträge</div>
${g.beschreibung ? `<div style="font-size:0.82rem;color:var(--color-muted);">${esc(g.beschreibung.substring(0,80))}${g.beschreibung.length>80?'…':''}</div>` : ''} ${g.beschreibung ? `<div style="font-size:0.82rem;color:var(--color-muted);">${esc(g.beschreibung.substring(0,80))}${g.beschreibung.length>80?'…':''}</div>` : ''}
<div class="card-notif" id="notif-${g.gruppeId}"></div> <div class="card-notif" id="notif-${g.gruppeId}"></div>
${actions ? `<div class="gruppe-card-actions">${actions}</div>` : ''}
</div> </div>
</div>`; </div>`;
} }
@@ -223,37 +183,6 @@
})); }));
} }
async function doSearch() {
const q = document.getElementById('searchInput').value.trim();
if (!q) return;
try {
const res = await fetch('/gruppen/search?q=' + encodeURIComponent(q));
if (!res.ok) return;
const data = await res.json();
const grid = document.getElementById('discoverGrid');
const hint = document.getElementById('discoverHint');
grid.innerHTML = '';
if (data.length === 0) { hint.textContent = 'Keine Gruppen gefunden.'; hint.style.display = ''; return; }
hint.style.display = 'none';
data.forEach(g => grid.insertAdjacentHTML('beforeend', gruppeCard(g, true)));
} catch(e) { console.error(e); }
}
async function joinGruppe(gruppeId, btn) {
btn.disabled = true;
btn.textContent = '…';
try {
const res = await fetch('/gruppen/' + gruppeId + '/join', { method: 'POST', headers:{'Content-Type':'application/json'}, body:'{}' });
if (res.ok || res.status === 201) {
btn.textContent = 'Beigetreten ✓';
setTimeout(loadMine, 500);
} else {
btn.disabled = false;
btn.textContent = 'Beitreten';
}
} catch(e) { btn.disabled = false; btn.textContent = 'Beitreten'; }
}
async function loadRequests() { async function loadRequests() {
try { try {
const reqRes = await fetch('/gruppen/requests/mine'); const reqRes = await fetch('/gruppen/requests/mine');
@@ -333,42 +262,6 @@
} catch(e) { showCreateError('Fehler: ' + e.message); } } catch(e) { showCreateError('Fehler: ' + e.message); }
} }
// ── Join dialog ──
let pendingJoinGruppeId = null;
function openJoinDialog(gruppeId, name) {
pendingJoinGruppeId = gruppeId;
document.getElementById('joinDialogGroupName').textContent = name;
document.getElementById('joinNachricht').value = '';
document.getElementById('joinDialog').classList.add('visible');
}
function closeJoinDialog() { document.getElementById('joinDialog').classList.remove('visible'); pendingJoinGruppeId = null; }
async function sendJoinRequest() {
if (!pendingJoinGruppeId) return;
document.getElementById('joinError').style.display = 'none';
const nachricht = document.getElementById('joinNachricht').value.trim() || null;
try {
const res = await fetch('/gruppen/' + pendingJoinGruppeId + '/join', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ nachricht })
});
if (res.ok || res.status === 201) {
closeJoinDialog();
doSearch();
} else {
const el = document.getElementById('joinError');
el.textContent = 'Fehler beim Senden der Anfrage.';
el.style.display = 'block';
}
} catch(e) {
const el = document.getElementById('joinError');
el.textContent = 'Fehler: ' + e.message;
el.style.display = 'block';
}
}
// ── Image preview ── // ── Image preview ──
function previewBild(input, previewId, dataId) { function previewBild(input, previewId, dataId) {
@@ -401,9 +294,6 @@
document.getElementById('createDialog').addEventListener('click', e => { document.getElementById('createDialog').addEventListener('click', e => {
if (e.target === document.getElementById('createDialog')) closeCreateDialog(); if (e.target === document.getElementById('createDialog')) closeCreateDialog();
}); });
document.getElementById('joinDialog').addEventListener('click', e => {
if (e.target === document.getElementById('joinDialog')) closeJoinDialog();
});
loadMine(); loadMine();
</script> </script>

View File

@@ -360,7 +360,7 @@
const list = document.getElementById('convList'); const list = document.getElementById('convList');
list.innerHTML = ''; list.innerHTML = '';
if (convs.length === 0) { if (convs.length === 0) {
list.innerHTML = '<li style="padding:1rem; color:var(--color-muted); font-size:0.9rem;">Noch keine Nachrichten. <a href="/community/personen-suchen.html" style="color:var(--color-primary);">Personen suchen</a></li>'; list.innerHTML = '<li style="padding:1rem; color:var(--color-muted); font-size:0.9rem;">Noch keine Nachrichten. <a href="/search.html" style="color:var(--color-primary);">Personen suchen</a></li>';
return; return;
} }
convs.forEach(c => { convs.forEach(c => {

View File

@@ -1,206 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Personen suchen xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.search-bar {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.search-bar input { flex: 1; }
.search-bar button { width: auto; margin-top: 0; padding: 0.65rem 1.25rem; }
.user-list { list-style: none; margin: 0; padding: 0; }
.user-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 0;
border-bottom: 1px solid var(--color-secondary);
}
.user-item:last-child { border-bottom: none; }
.user-avatar {
width: 42px; height: 42px;
border-radius: 50%;
background: var(--color-secondary);
display: flex; align-items: center; justify-content: center;
font-size: 1.2rem;
flex-shrink: 0;
overflow: hidden;
border: 1px solid var(--color-secondary);
}
.user-avatar img { width: 100%; height: 100%; object-fit: cover; }
.user-name { font-weight: 600; flex: 1; }
.user-actions { display: flex; gap: 0.5rem; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; }
.user-actions button, .user-actions a.btn {
margin-top: 0;
padding: 0.35rem 0.75rem;
font-size: 0.8rem;
width: auto;
}
.user-profile-link {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
min-width: 0;
text-decoration: none;
color: inherit;
}
.user-profile-link:hover .user-name { color: var(--color-primary); }
.hint { color: var(--color-muted); font-size: 0.9rem; margin-top: 0.5rem; }
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<h1 style="margin-bottom: 1.25rem;">Personen suchen</h1>
<div class="search-bar">
<input type="text" id="searchInput" placeholder="Name eingeben (mind. 2 Zeichen)…" autocomplete="off">
<button onclick="doSearch()">Suchen</button>
</div>
<ul class="user-list" id="resultList"></ul>
<p class="hint" id="hint">Gib mindestens 2 Zeichen ein, um zu suchen.</p>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/nav.js"></script>
<script src="/js/social-sidebar.js"></script>
<script>
let debounceTimer;
document.getElementById('searchInput').addEventListener('input', function () {
clearTimeout(debounceTimer);
const q = this.value.trim();
if (q.length < 2) {
document.getElementById('resultList').innerHTML = '';
document.getElementById('hint').textContent = 'Gib mindestens 2 Zeichen ein, um zu suchen.';
document.getElementById('hint').style.display = '';
return;
}
document.getElementById('hint').style.display = 'none';
debounceTimer = setTimeout(doSearch, 400);
});
document.getElementById('searchInput').addEventListener('keydown', e => {
if (e.key === 'Enter') { clearTimeout(debounceTimer); doSearch(); }
});
async function doSearch() {
const q = document.getElementById('searchInput').value.trim();
if (q.length < 2) return;
try {
const res = await fetch('/social/users/search?q=' + encodeURIComponent(q));
if (!res.ok) return;
renderResults(await res.json());
} catch (e) { console.error(e); }
}
function renderResults(users) {
const list = document.getElementById('resultList');
const hint = document.getElementById('hint');
list.innerHTML = '';
if (users.length === 0) {
hint.textContent = 'Keine Ergebnisse gefunden.';
hint.style.display = '';
return;
}
hint.style.display = 'none';
users.forEach(u => {
const avatar = u.profilePicture
? `<img src="data:image/png;base64,${u.profilePicture}" alt="">`
: '◉';
list.insertAdjacentHTML('beforeend', `
<li class="user-item" data-user-id="${u.userId}">
<a href="/community/benutzer.html?userId=${u.userId}" class="user-profile-link">
<div class="user-avatar">${avatar}</div>
<div class="user-name">${esc(u.name)}</div>
</a>
<div class="user-actions">${buildActions(u)}</div>
</li>`);
});
}
function buildActions(u) {
if (u.friendStatus === 'FRIEND') {
return `<a href="/community/nachrichten.html?userId=${u.userId}" class="btn" style="background:var(--color-secondary); color:var(--color-text);">✉ Nachricht</a>`;
}
if (u.friendStatus === 'PENDING_SENT') {
return `<button disabled>Anfrage gesendet</button>`;
}
if (u.friendStatus === 'PENDING_RECEIVED') {
return `<button onclick="acceptByUserId('${u.userId}', this)">✓ Annehmen</button>`;
}
return `<button onclick="sendRequest('${u.userId}', this)">+ Freund hinzufügen</button>`;
}
async function sendRequest(receiverId, btn) {
btn.disabled = true;
btn.textContent = 'Wird gesendet…';
try {
const res = await fetch('/social/friends/request', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ receiverId })
});
btn.textContent = (res.ok || res.status === 201 || res.status === 409) ? 'Anfrage gesendet' : 'Fehler';
} catch (e) {
btn.disabled = false;
btn.textContent = '+ Freund hinzufügen';
}
}
async function acceptByUserId(senderId, btn) {
btn.disabled = true;
btn.textContent = '…';
try {
const pendingRes = await fetch('/social/friends/pending');
const pending = await pendingRes.json();
const f = pending.find(p => p.user.userId === senderId);
if (!f) { btn.textContent = 'Fehler'; return; }
const res = await fetch('/social/friends/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ friendshipId: f.friendshipId })
});
if (res.ok) {
btn.textContent = '✓ Freund';
const item = btn.closest('.user-item');
if (item) {
item.querySelector('.user-actions').innerHTML =
`<a href="/community/nachrichten.html?userId=${senderId}" class="btn" style="background:var(--color-secondary); color:var(--color-text);">✉ Nachricht</a>`;
}
} else {
btn.disabled = false;
btn.textContent = '✓ Annehmen';
}
} catch (e) {
btn.disabled = false;
btn.textContent = '✓ Annehmen';
}
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
</script>
</body>
</html>

View File

@@ -0,0 +1,371 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Einladungen xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.inv-tabs {
display: flex;
gap: 0;
margin-bottom: 1.5rem;
border-bottom: 1px solid var(--color-secondary);
}
.inv-tab {
background: none;
border: none;
border-bottom: 2px solid transparent;
border-radius: 0;
padding: 0.5rem 1.1rem;
font-size: 0.95rem;
font-weight: 600;
color: var(--color-muted);
cursor: pointer;
margin: 0 0 -1px;
width: auto;
transition: color 0.15s, border-color 0.15s;
}
.inv-tab.active { color: var(--color-primary); border-bottom-color: var(--color-primary); }
.inv-tab:hover:not(.active) { color: var(--color-text); background: none; }
.inv-section-label {
font-size: 0.8rem; font-weight: 600; color: var(--color-muted);
text-transform: uppercase; letter-spacing: 0.05em;
margin: 1.5rem 0 0.65rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--color-secondary);
}
.inv-card {
display: flex; gap: 0.85rem; align-items: center;
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 12px;
padding: 0.85rem 1rem;
margin-bottom: 0.6rem;
text-decoration: none;
color: inherit;
transition: border-color 0.15s;
}
.inv-card:hover { border-color: var(--color-primary); }
.inv-avatar {
width: 44px; height: 44px; border-radius: 50%;
background: var(--color-secondary);
display: flex; align-items: center; justify-content: center;
font-size: 1.2rem; overflow: hidden; flex-shrink: 0;
}
.inv-avatar img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
.inv-body { flex: 1; min-width: 0; }
.inv-from { font-weight: 600; font-size: 0.95rem;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.inv-type { font-size: 0.78rem; color: var(--color-muted); margin-top: 0.15rem; }
.inv-actions { display: flex; gap: 0.5rem; flex-shrink: 0; flex-wrap: wrap; align-items: center; }
.inv-btn {
background: var(--color-primary);
color: #fff; border: none; border-radius: 8px;
padding: 0.38rem 0.9rem; font-size: 0.85rem; font-weight: 600;
cursor: pointer; margin: 0; width: auto;
text-decoration: none; display: inline-flex; align-items: center;
transition: opacity 0.15s;
}
.inv-btn:hover { opacity: 0.85; }
.inv-btn.outline {
background: none; border: 1px solid var(--color-secondary);
color: var(--color-muted);
}
.inv-btn.outline:hover { border-color: var(--color-primary); color: var(--color-primary); opacity: 1; }
.inv-arrow { font-size: 0.85rem; color: var(--color-primary); font-weight: 600; flex-shrink: 0; }
.inv-status { font-size: 0.8rem; color: var(--color-muted); }
.inv-empty { text-align: center; color: var(--color-muted); padding: 3rem 1rem; font-size: 0.95rem; }
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<h1 style="margin:0 0 1.25rem;">Einladungen</h1>
<div class="inv-tabs">
<button class="inv-tab" id="tabBtnErhalten" onclick="switchTab('erhalten')">Erhalten</button>
<button class="inv-tab" id="tabBtnGesendet" onclick="switchTab('gesendet')">Gesendet</button>
</div>
<!-- ── Erhalten ─────────────────────────────────────────────────── -->
<div id="panelErhalten">
<div id="loadingErhalten" style="text-align:center;color:var(--color-muted);padding:2rem 0;">Wird geladen…</div>
<div id="secVanilla" style="display:none;">
<div class="inv-section-label">🎭 Vanilla Game</div>
<div id="listVanilla"></div>
</div>
<div id="secBdsm" style="display:none;">
<div class="inv-section-label">⛓ BDSM Game</div>
<div id="listBdsm"></div>
</div>
<div id="secChastity" style="display:none;">
<div class="inv-section-label">🔒 Chastity Game</div>
<div id="listChastity"></div>
</div>
<div id="emptyErhalten" style="display:none;">
<div class="inv-empty">Du hast aktuell keine offenen Einladungen.</div>
</div>
</div>
<!-- ── Gesendet ─────────────────────────────────────────────────── -->
<div id="panelGesendet" style="display:none;">
<div id="loadingGesendet" style="text-align:center;color:var(--color-muted);padding:2rem 0;">Wird geladen…</div>
<div id="secSentVanilla" style="display:none;">
<div class="inv-section-label">🎭 Vanilla Game</div>
<div id="listSentVanilla"></div>
</div>
<div id="secSentBdsm" style="display:none;">
<div class="inv-section-label">⛓ BDSM Game</div>
<div id="listSentBdsm"></div>
</div>
<div id="secSentChastity" style="display:none;">
<div class="inv-section-label">🔒 Chastity Game</div>
<div id="listSentChastity"></div>
</div>
<div id="emptySent" style="display:none;">
<div class="inv-empty">Du hast aktuell keine gesendeten Einladungen.</div>
</div>
</div>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/nav.js"></script>
<script>
function esc(s) {
return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ── Tab-Switching ────────────────────────────────────────────────────────
function switchTab(tab) {
const erhalten = tab === 'erhalten';
document.getElementById('panelErhalten').style.display = erhalten ? '' : 'none';
document.getElementById('panelGesendet').style.display = erhalten ? 'none' : '';
document.getElementById('tabBtnErhalten').classList.toggle('active', erhalten);
document.getElementById('tabBtnGesendet').classList.toggle('active', !erhalten);
const url = new URL(location.href);
erhalten ? url.searchParams.delete('tab') : url.searchParams.set('tab', tab);
history.replaceState(null, '', url);
}
const initTab = new URLSearchParams(location.search).get('tab') || 'erhalten';
switchTab(initTab);
// ── Erhalten ──────────────────────────────────────────────────────────────
async function loadErhalten() {
let shown = 0;
try {
const [vRes, bRes, cRes] = await Promise.all([
fetch('/vanilla/einladung/pending'),
fetch('/bdsm/einladung/pending'),
fetch('/lockee/invitations/mine'),
]);
// Vanilla
if (vRes.ok) {
const list = await vRes.json();
if (list.length) {
document.getElementById('secVanilla').style.display = '';
document.getElementById('listVanilla').innerHTML = list.map(e => `
<div class="inv-card" id="vcard-${esc(e.id)}">
<div class="inv-avatar">
${e.inviterAvatar
? `<img src="data:image/png;base64,${e.inviterAvatar}" alt="">`
: '🎭'}
</div>
<div class="inv-body">
<div class="inv-from">${esc(e.inviterName || 'Jemand')}</div>
<div class="inv-type">Vanilla-Spieleinladung</div>
</div>
<div class="inv-actions">
<button class="inv-btn" onclick="acceptVanilla(${esc(e.id)})">Annehmen</button>
<button class="inv-btn outline" onclick="declineVanilla(${esc(e.id)})">Ablehnen</button>
</div>
</div>`).join('');
shown += list.length;
}
}
// BDSM
if (bRes.ok) {
const list = await bRes.json();
if (list.length) {
document.getElementById('secBdsm').style.display = '';
document.getElementById('listBdsm').innerHTML = list.map(e => `
<a class="inv-card" href="/games/bdsm/bdsm-einladung.html?id=${esc(e.id)}">
<div class="inv-avatar">
${e.inviterAvatar
? `<img src="data:image/png;base64,${e.inviterAvatar}" alt="">`
: '⛓'}
</div>
<div class="inv-body">
<div class="inv-from">${esc(e.inviterName || 'Jemand')}</div>
<div class="inv-type">BDSM-Spieleinladung</div>
</div>
<span class="inv-arrow">Ansehen →</span>
</a>`).join('');
shown += list.length;
}
}
// Chastity
if (cRes.ok) {
const list = await cRes.json();
if (list.length) {
document.getElementById('secChastity').style.display = '';
document.getElementById('listChastity').innerHTML = list.map(e => `
<a class="inv-card" href="/games/chastity/joinlock.html?token=${esc(e.token)}">
<div class="inv-avatar">
${e.keyholderProfilePic
? `<img src="data:image/png;base64,${e.keyholderProfilePic}" alt="">`
: '🔒'}
</div>
<div class="inv-body">
<div class="inv-from">${esc(e.keyholderName || 'Jemand')}</div>
<div class="inv-type">Keuschheitslock: ${esc(e.lockName || '')}</div>
</div>
<span class="inv-arrow">Ansehen →</span>
</a>`).join('');
shown += list.length;
}
}
} catch (_) {}
document.getElementById('loadingErhalten').style.display = 'none';
if (!shown) document.getElementById('emptyErhalten').style.display = '';
}
// Vanilla inline annehmen / ablehnen
async function acceptVanilla(id) {
const card = document.getElementById('vcard-' + id);
if (card) card.querySelector('.inv-actions').innerHTML = '<span class="inv-status">Wird gespeichert…</span>';
try {
const res = await fetch(`/vanilla/einladung/${id}/antwort`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accepted: true, mode: 'OWN_DEVICE' }),
});
if (res.ok) {
window.location.href = '/games/vanilla/neuvanilla.html';
} else {
if (card) card.querySelector('.inv-actions').innerHTML = '<span class="inv-status" style="color:var(--color-primary)">Fehler beim Annehmen.</span>';
}
} catch (_) {
if (card) card.querySelector('.inv-actions').innerHTML = '<span class="inv-status" style="color:var(--color-primary)">Fehler.</span>';
}
}
async function declineVanilla(id) {
const card = document.getElementById('vcard-' + id);
if (card) card.querySelector('.inv-actions').innerHTML = '<span class="inv-status">Wird gespeichert…</span>';
try {
await fetch(`/vanilla/einladung/${id}/antwort`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accepted: false, mode: null }),
});
if (card) card.remove();
if (!document.getElementById('listVanilla').children.length) {
document.getElementById('secVanilla').style.display = 'none';
}
checkEmptyErhalten();
} catch (_) {}
}
function checkEmptyErhalten() {
const anyVisible = ['secVanilla','secBdsm','secChastity']
.some(id => document.getElementById(id).style.display !== 'none');
document.getElementById('emptyErhalten').style.display = anyVisible ? 'none' : '';
}
// ── Gesendet ──────────────────────────────────────────────────────────────
async function loadGesendet() {
let shown = 0;
try {
const [bRes, vRes, cRes] = await Promise.all([
fetch('/bdsm/einladung/meine-aktive'),
fetch('/vanilla/einladung/meine-aktive'),
fetch('/keyholder/invitations/mine'),
]);
// Vanilla gesendete Einladung
if (vRes.ok) {
const e = await vRes.json();
if (e) {
document.getElementById('secSentVanilla').style.display = '';
document.getElementById('listSentVanilla').innerHTML = `
<div class="inv-card">
<div class="inv-avatar">🎭</div>
<div class="inv-body">
<div class="inv-from">Vanilla-Einladung gesendet</div>
<div class="inv-type">Warte auf Antwort…</div>
</div>
<a class="inv-btn" href="/games/vanilla/neuvanilla.html">Zum Spiel →</a>
</div>`;
shown++;
}
}
// BDSM gesendete Einladung
if (bRes.ok) {
const e = await bRes.json();
if (e) {
document.getElementById('secSentBdsm').style.display = '';
document.getElementById('listSentBdsm').innerHTML = `
<div class="inv-card">
<div class="inv-avatar">⛓</div>
<div class="inv-body">
<div class="inv-from">BDSM-Einladung gesendet</div>
<div class="inv-type">Warte auf Antwort…</div>
</div>
<a class="inv-btn" href="/games/bdsm/neubdsm.html">Zum Spiel →</a>
</div>`;
shown++;
}
}
// Chastity gesendete Einladungen (als Keyholder)
if (cRes.ok) {
const list = await cRes.json();
if (Array.isArray(list) && list.length) {
document.getElementById('secSentChastity').style.display = '';
document.getElementById('listSentChastity').innerHTML = list.map(e => `
<div class="inv-card">
<div class="inv-avatar">🔒</div>
<div class="inv-body">
<div class="inv-from">${esc(e.lockeeName || e.lockeeEmail || 'Eingeladene Person')}</div>
<div class="inv-type">Lock: ${esc(e.lockName || '')} · ${esc(e.status || 'Ausstehend')}</div>
</div>
<a class="inv-btn outline" href="/games/chastity/keyholder.html">Keyholder →</a>
</div>`).join('');
shown += list.length;
}
}
} catch (_) {}
document.getElementById('loadingGesendet').style.display = 'none';
if (!shown) document.getElementById('emptySent').style.display = '';
}
// Auth-Check & Start
fetch('/login/me')
.then(r => { if (r.status === 401) window.location.href = '/login.html'; return r.ok ? r.json() : null; })
.then(user => {
if (user) { loadErhalten(); loadGesendet(); }
})
.catch(() => { window.location.href = '/login.html'; });
</script>
</body>
</html>

View File

@@ -1213,6 +1213,7 @@
const id = addPlayer(p.name, i === 0, i === 0, false, false); const id = addPlayer(p.name, i === 0, i === 0, false, false);
if (id) { restorePlayer(id, p); if (p.userId) userIdToInfo[p.userId] = { playerId: id, name: p.name }; } if (id) { restorePlayer(id, p); if (p.userId) userIdToInfo[p.userId] = { playerId: id, name: p.name }; }
}); });
Object.entries(userIdToInfo).forEach(([userId, info]) => { pruefeChastityConstraint(info.playerId, userId); });
await ladeEinladungenAusDb(userIdToInfo); await ladeEinladungenAusDb(userIdToInfo);
restoredFromSetup = true; restoredFromSetup = true;
} else { } else {
@@ -1273,14 +1274,15 @@
if (draft.gruppenJson) { sessionStorage.setItem('vanilla-session-gruppen', draft.gruppenJson); savedGruppen = new Set(JSON.parse(draft.gruppenJson)); } if (draft.gruppenJson) { sessionStorage.setItem('vanilla-session-gruppen', draft.gruppenJson); savedGruppen = new Set(JSON.parse(draft.gruppenJson)); }
} }
} }
const selfGeschlecht = user?.geschlecht || null; const defaults = await fetch('/user/me/bdsm-defaults').then(r => r.ok ? r.json() : {}).catch(() => ({}));
const selfWerkzeuge = selfGeschlecht ? (WERKZEUGE_DEFAULTS[selfGeschlecht] || []) : []; const selfGeschlecht = defaults.geschlecht || user?.geschlecht || null;
const selfWerkzeuge = defaults.werkzeuge?.length ? defaults.werkzeuge : (selfGeschlecht ? (WERKZEUGE_DEFAULTS[selfGeschlecht] || []) : []);
const selfId = addPlayer(user ? user.name : '', true, true, !!selfGeschlecht, false); const selfId = addPlayer(user ? user.name : '', true, true, !!selfGeschlecht, false);
if (selfId) { if (selfId) {
if (user?.profilePicture) { playerProfilePics[selfId] = user.profilePicture; updatePlayerHeader(selfId, user.name); } if (user?.profilePicture) { playerProfilePics[selfId] = user.profilePicture; updatePlayerHeader(selfId, user.name); }
if (playerIds.length < MAX_PLAYERS) addPlayer(); if (playerIds.length < MAX_PLAYERS) addPlayer();
restorePlayer(selfId, { geschlecht: selfGeschlecht, spieltMit: [], rollen: [], werkzeuge: selfWerkzeuge }); restorePlayer(selfId, { geschlecht: selfGeschlecht, spieltMit: defaults.spieltMit || [], rollen: defaults.rollen || [], werkzeuge: selfWerkzeuge });
pruefeChastityConstraint(selfId, myUserId); if (myUserId) pruefeChastityConstraint(selfId, myUserId);
} }
await ladeEinladungenAusDb(null); await ladeEinladungenAusDb(null);
} }
@@ -1324,8 +1326,9 @@
if (user?.profilePicture) { playerProfilePics[guestOwnPlayerId] = user.profilePicture; updatePlayerHeader(guestOwnPlayerId, user?.name || ''); } if (user?.profilePicture) { playerProfilePics[guestOwnPlayerId] = user.profilePicture; updatePlayerHeader(guestOwnPlayerId, user?.name || ''); }
restorePlayer(guestOwnPlayerId, { geschlecht: user?.geschlecht || null, spieltMit: [], rollen: [], werkzeuge: [] }); const defaults = await fetch('/user/me/bdsm-defaults').then(r => r.ok ? r.json() : {}).catch(() => ({}));
pruefeChastityConstraint(guestOwnPlayerId, myUserId); restorePlayer(guestOwnPlayerId, { geschlecht: defaults.geschlecht || user?.geschlecht || null, spieltMit: defaults.spieltMit || [], rollen: defaults.rollen || [], werkzeuge: defaults.werkzeuge || [] });
if (myUserId) pruefeChastityConstraint(guestOwnPlayerId, myUserId);
document.getElementById('acc-grundeinstellungen-btn').classList.add('is-open'); document.getElementById('acc-grundeinstellungen-btn').classList.add('is-open');
document.getElementById('acc-grundeinstellungen-body').classList.add('is-open'); document.getElementById('acc-grundeinstellungen-body').classList.add('is-open');

View File

@@ -0,0 +1,217 @@
(function () {
'use strict';
// ── Styles ──────────────────────────────────────────────────────────────
const style = document.createElement('style');
style.textContent = `
.post-hashtag {
color: var(--color-primary);
text-decoration: none;
font-weight: 600;
}
.post-hashtag:hover { text-decoration: underline; }
.hashtag-dropdown {
position: fixed;
z-index: 600;
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 8px;
min-width: 170px;
max-width: 260px;
box-shadow: 0 4px 20px rgba(0,0,0,0.45);
overflow: hidden;
}
.hashtag-dropdown-item {
padding: 0.45rem 0.9rem;
cursor: pointer;
font-size: 0.88rem;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.hashtag-dropdown-item:hover,
.hashtag-dropdown-item.active {
background: var(--color-secondary);
color: var(--color-primary);
}
.hashtag-dropdown-create {
font-style: italic;
border-top: 1px solid var(--color-secondary);
color: var(--color-primary);
}
.hashtag-dropdown-hint {
padding: 0.45rem 0.9rem;
font-size: 0.82rem;
color: var(--color-text);
opacity: 0.6;
font-style: italic;
}
`;
document.head.appendChild(style);
// ── Escape helper (works even if shared.js not yet loaded) ───────────────
function esc(s) {
return String(s ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ── renderTextWithHashtags ────────────────────────────────────────────────
window.renderTextWithHashtags = function (text) {
if (!text) return '';
const PATTERN = /#([\wäöüÄÖÜß]{1,100})/g;
const parts = [];
let lastIndex = 0;
let match;
while ((match = PATTERN.exec(text)) !== null) {
parts.push(esc(text.slice(lastIndex, match.index)));
const tag = match[1].toLowerCase();
parts.push(
`<a href="/community/feed.html?tag=${encodeURIComponent(tag)}" ` +
`class="post-hashtag" onclick="event.stopPropagation()">#${esc(match[1])}</a>`
);
lastIndex = match.index + match[0].length;
}
parts.push(esc(text.slice(lastIndex)));
return parts.join('');
};
// ── attachHashtagAutocomplete ─────────────────────────────────────────────
window.attachHashtagAutocomplete = function (textarea) {
let dropdownEl = null;
let suggestions = [];
let selectedIdx = -1;
let debounce = null;
// Returns { prefix, hashStart } if cursor is inside a #word, else null
function getHashAtCursor() {
const pos = textarea.selectionStart;
const text = textarea.value;
let i = pos - 1;
while (i >= 0 && /[\wäöüÄÖÜß]/.test(text[i])) i--;
if (i >= 0 && text[i] === '#') {
return { prefix: text.slice(i + 1, pos), hashStart: i };
}
if (i === -1 && text.length > 0 && text[0] === '#' && pos > 0) {
return { prefix: text.slice(1, pos), hashStart: 0 };
}
return null;
}
function positionDropdown() {
if (!dropdownEl) return;
const r = textarea.getBoundingClientRect();
dropdownEl.style.top = (r.bottom + window.scrollY + 2) + 'px';
dropdownEl.style.left = (r.left + window.scrollX) + 'px';
}
function highlight() {
if (!dropdownEl) return;
dropdownEl.querySelectorAll('.hashtag-dropdown-item').forEach((el, i) => {
el.classList.toggle('active', i === selectedIdx);
});
}
function removeDropdown() {
dropdownEl?.remove();
dropdownEl = null;
suggestions = [];
selectedIdx = -1;
}
function showDropdown(items, prefix) {
removeDropdown();
const lPrefix = prefix ? prefix.toLowerCase() : '';
const hasCreate = lPrefix.length > 0 && !items.some(t => t === lPrefix);
if (!items.length && !hasCreate) {
if (prefix !== undefined && prefix.length === 0) {
// Nur "#" getippt, noch keine populären Tags
dropdownEl = document.createElement('div');
dropdownEl.className = 'hashtag-dropdown';
const hint = document.createElement('div');
hint.className = 'hashtag-dropdown-hint';
hint.textContent = 'Tippe weiter um einen Hashtag zu erstellen';
dropdownEl.appendChild(hint);
document.body.appendChild(dropdownEl);
positionDropdown();
}
return;
}
suggestions = hasCreate ? [...items, lPrefix] : [...items];
dropdownEl = document.createElement('div');
dropdownEl.className = 'hashtag-dropdown';
suggestions.forEach((tag, i) => {
const isCreate = hasCreate && i === suggestions.length - 1;
const item = document.createElement('div');
item.className = 'hashtag-dropdown-item' + (isCreate ? ' hashtag-dropdown-create' : '');
item.textContent = (isCreate ? '+ #' : '#') + tag;
item.addEventListener('mousedown', e => { e.preventDefault(); insertTag(tag); });
item.addEventListener('mouseover', () => { selectedIdx = i; highlight(); });
dropdownEl.appendChild(item);
});
document.body.appendChild(dropdownEl);
positionDropdown();
}
function insertTag(tag) {
const info = getHashAtCursor();
if (!info) { removeDropdown(); return; }
const v = textarea.value;
const cursor = textarea.selectionStart;
const before = v.slice(0, info.hashStart);
const after = v.slice(cursor);
const inserted = '#' + tag + ' ';
textarea.value = before + inserted + after;
const newPos = before.length + inserted.length;
textarea.setSelectionRange(newPos, newPos);
textarea.focus();
textarea.dispatchEvent(new Event('input', { bubbles: true }));
removeDropdown();
}
textarea.addEventListener('input', () => {
clearTimeout(debounce);
const info = getHashAtCursor();
if (!info) { removeDropdown(); return; }
debounce = setTimeout(async () => {
try {
const url = info.prefix.length === 0
? '/hashtags/popular?limit=6'
: '/hashtags/suggest?q=' + encodeURIComponent(info.prefix) + '&limit=6';
const res = await fetch(url);
if (res.ok) showDropdown(await res.json(), info.prefix);
else showDropdown([], info.prefix);
} catch { removeDropdown(); }
}, 150);
});
textarea.addEventListener('keydown', e => {
if (!dropdownEl) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
selectedIdx = Math.min(selectedIdx + 1, suggestions.length - 1);
highlight();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
selectedIdx = Math.max(selectedIdx - 1, 0);
highlight();
} else if ((e.key === 'Enter' || e.key === 'Tab') && selectedIdx >= 0) {
e.preventDefault();
insertTag(suggestions[selectedIdx]);
} else if (e.key === 'Escape') {
removeDropdown();
}
});
textarea.addEventListener('blur', () => setTimeout(removeDropdown, 150));
window.addEventListener('scroll', positionDropdown, { passive: true });
window.addEventListener('resize', positionDropdown, { passive: true });
};
})();

View File

@@ -19,7 +19,7 @@
box-shadow: 0 2px 12px rgba(0,0,0,0.4); box-shadow: 0 2px 12px rgba(0,0,0,0.4);
z-index: 500; z-index: 500;
align-items: center; align-items: center;
padding: 0 0.25rem; padding: 0 4px 0 0.25rem;
} }
.mobile-topbar-logo { .mobile-topbar-logo {
position: absolute; position: absolute;
@@ -43,9 +43,9 @@
background: none; background: none;
border: none; border: none;
color: var(--color-text); color: var(--color-text);
font-size: 1.725rem; font-size: 1.3rem;
line-height: 1; line-height: 1;
padding: 0.75rem 0.675rem; padding: 0.55rem 0.6rem;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -301,7 +301,7 @@
'/community/gruppen.html', '/community/gruppe.html', '/community/gruppen.html', '/community/gruppe.html',
'/community/locations.html', '/community/location-detail.html', '/community/locations.html', '/community/location-detail.html',
'/community/events.html', '/community/event-detail.html', '/community/events.html', '/community/event-detail.html',
'/community/abonnements.html', '/community/personen-suchen.html', '/community/abonnements.html',
'/community/benutzer.html', '/community/benutzer.html',
])} ])}
${column('colDating', 'Dating', col3Html, ['/dating/'])} ${column('colDating', 'Dating', col3Html, ['/dating/'])}

View File

@@ -5,12 +5,19 @@
// ── Bereichs-Definitionen ──────────────────────────────────────────────── // ── Bereichs-Definitionen ────────────────────────────────────────────────
const SECTIONS = { const SECTIONS = {
common: {
prefixes: ['/games/common/'],
items: [
{ href: '/games/vanilla/neuvanilla.html', icon: 'VANILLA', label: 'Vanilla Game' },
{ href: '/games/bdsm/neubdsm.html', icon: 'BDSM', label: 'BDSM Game' },
{ href: '/games/chastity/neulock.html', icon: 'CHASTITY', label: 'Chastity Game' },
],
},
social: { social: {
prefixes: ['/community/'], prefixes: ['/community/'],
exclude: [ exclude: [
'/community/nachrichten.html', '/community/nachrichten.html',
'/community/benachrichtigungen.html', '/community/benachrichtigungen.html',
'/community/einladungen.html',
], ],
items: [ items: [
{ href: '/community/feed.html', icon: 'FEED', label: 'Feed' }, { href: '/community/feed.html', icon: 'FEED', label: 'Feed' },

View File

@@ -168,18 +168,26 @@
async function doSearch(q, overlay) { async function doSearch(q, overlay) {
try { try {
const res = await fetch('/search?q=' + encodeURIComponent(q) + '&limit=3'); const tagQuery = q.startsWith('#') ? q.slice(1) : q;
if (!res.ok) { overlay.innerHTML = '<div class="topbar-search-hint">Fehler bei der Suche.</div>'; return; } const [searchRes, gruppenRes, hashtagRes] = await Promise.all([
const data = await res.json(); fetch('/search?q=' + encodeURIComponent(q) + '&limit=3'),
const { users = [], locations = [], events = [] } = data; fetch('/gruppen/search?q=' + encodeURIComponent(q)),
fetch('/hashtags/suggest?q=' + encodeURIComponent(tagQuery) + '&limit=4')
]);
if (!users.length && !locations.length && !events.length) { const data = searchRes.ok ? await searchRes.json() : {};
overlay.innerHTML = '<div class="topbar-search-hint">Keine Ergebnisse.</div>'; const gruppen = gruppenRes.ok ? await gruppenRes.json() : [];
return; const hashtags = hashtagRes.ok ? await hashtagRes.json() : [];
}
const { users = [], locations = [], events = [] } = data;
const gruppenSlice = gruppen.slice(0, 3);
let html = ''; let html = '';
if (!users.length && !locations.length && !events.length && !gruppenSlice.length && !hashtags.length) {
html += '<div class="topbar-search-hint">Keine Ergebnisse.</div>';
}
if (users.length) { if (users.length) {
html += `<div class="topbar-search-section">Personen</div>`; html += `<div class="topbar-search-section">Personen</div>`;
html += users.map(u => { html += users.map(u => {
@@ -213,6 +221,26 @@
}).join(''); }).join('');
} }
if (gruppenSlice.length) {
html += `<div class="topbar-search-section">Gruppen</div>`;
html += gruppenSlice.map(g => {
const av = g.bild
? `<img src="data:image/jpeg;base64,${esc(g.bild)}" class="topbar-search-avatar" alt="">`
: `<span class="topbar-search-avatar topbar-search-avatar--placeholder">👥</span>`;
return `<a href="/community/gruppe.html?gruppeId=${esc(g.gruppeId)}" class="topbar-search-result">
${av}<span>${esc(g.name)}</span></a>`;
}).join('');
}
if (hashtags.length) {
html += `<div class="topbar-search-section">Hashtags</div>`;
html += `<div style="display:flex;flex-wrap:wrap;gap:0.4rem;padding:0.4rem 0.75rem;">`;
html += hashtags.map(tag =>
`<a href="/community/feed.html?tag=${encodeURIComponent(tag)}" style="display:inline-block;padding:0.25rem 0.65rem;background:var(--color-secondary);border-radius:14px;font-size:0.82rem;font-weight:600;color:var(--color-primary);text-decoration:none;">#${esc(tag)}</a>`
).join('');
html += `</div>`;
}
html += `<a href="/search.html?q=${encodeURIComponent(q)}" class="topbar-search-all">Alle Ergebnisse anzeigen →</a>`; html += `<a href="/search.html?q=${encodeURIComponent(q)}" class="topbar-search-all">Alle Ergebnisse anzeigen →</a>`;
overlay.innerHTML = html; overlay.innerHTML = html;
} catch (e) { } catch (e) {

View File

@@ -163,6 +163,33 @@
transition: border-color 0.15s, color 0.15s; transition: border-color 0.15s, color 0.15s;
} }
.search-load-more:hover { border-color: var(--color-primary); color: var(--color-primary); background: none; } .search-load-more:hover { border-color: var(--color-primary); color: var(--color-primary); background: none; }
.hashtag-chip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.4rem 0.85rem;
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 20px;
color: var(--color-primary);
font-weight: 600;
font-size: 0.9rem;
text-decoration: none;
transition: border-color 0.15s, background 0.15s;
}
.hashtag-chip:hover { border-color: var(--color-primary); background: var(--color-secondary); }
/* Dialog (Gruppen-Beitritt) */
.dialog-backdrop { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.6); z-index:200; align-items:center; justify-content:center; }
.dialog-backdrop.visible { display:flex; }
.dialog { background:var(--color-card); border:1px solid var(--color-secondary); border-radius:12px; padding:1.75rem; width:100%; max-width:420px; box-shadow:0 8px 32px rgba(0,0,0,0.6); max-height:90vh; overflow-y:auto; }
.dialog h3 { color:var(--color-primary); font-size:1.1rem; margin-bottom:1.25rem; }
.dialog label { display:block; font-size:0.8rem; color:#aaa; margin-bottom:0.3rem; margin-top:1rem; }
.dialog textarea { width:100%; padding:0.6rem 0.85rem; border:1px solid var(--color-secondary); border-radius:6px; background:var(--color-secondary); color:var(--color-text); font-size:0.95rem; outline:none; transition:border-color 0.2s; resize:vertical; min-height:80px; box-sizing:border-box; }
.dialog textarea:focus { border-color:var(--color-primary); }
.dialog-actions { display:flex; justify-content:flex-end; gap:0.75rem; margin-top:1.5rem; }
.dialog-actions button { flex:none; margin:0; padding:0.55rem 1.1rem; font-size:0.9rem; width:auto; }
</style> </style>
</head> </head>
<body class="app"> <body class="app">
@@ -173,7 +200,7 @@
<div class="search-hero"> <div class="search-hero">
<div class="search-hero-input-wrap"> <div class="search-hero-input-wrap">
<span class="search-hero-icon" id="searchIcon"></span> <span class="search-hero-icon" id="searchIcon"></span>
<input type="text" id="searchInput" placeholder="Suchen nach Personen, Locations, Veranstaltungen…" <input type="text" id="searchInput" placeholder="Suchen nach Personen, Locations, Veranstaltungen, Gruppen…"
autocomplete="off" spellcheck="false"> autocomplete="off" spellcheck="false">
</div> </div>
</div> </div>
@@ -188,6 +215,12 @@
<button class="search-tab-btn" data-tab="events"> <button class="search-tab-btn" data-tab="events">
Veranstaltungen <span class="search-tab-count" id="countEvents">0</span> Veranstaltungen <span class="search-tab-count" id="countEvents">0</span>
</button> </button>
<button class="search-tab-btn" data-tab="gruppen">
Gruppen <span class="search-tab-count" id="countGruppen">0</span>
</button>
<button class="search-tab-btn" data-tab="hashtags">
Hashtags <span class="search-tab-count" id="countHashtags">0</span>
</button>
</div> </div>
<div class="search-panel active" id="panel-users"> <div class="search-panel active" id="panel-users">
@@ -207,6 +240,31 @@
<div class="search-grid" id="gridEvents"></div> <div class="search-grid" id="gridEvents"></div>
<button class="search-load-more" id="moreEvents" style="display:none;">Mehr laden</button> <button class="search-load-more" id="moreEvents" style="display:none;">Mehr laden</button>
</div> </div>
<div class="search-panel" id="panel-gruppen">
<div class="search-loading" id="loadingGruppen" style="display:none;">Wird geladen…</div>
<div class="search-grid" id="gridGruppen"></div>
</div>
<div class="search-panel" id="panel-hashtags">
<div class="search-loading" id="loadingHashtags" style="display:none;">Wird geladen…</div>
<div id="gridHashtags" style="display:flex; flex-wrap:wrap; gap:0.6rem; padding-top:0.25rem;"></div>
</div>
</div>
</div>
<!-- Beitrittsanfrage Dialog -->
<div class="dialog-backdrop" id="searchJoinDialog">
<div class="dialog">
<h3>Beitrittsanfrage senden</h3>
<p id="searchJoinGroupName" style="font-weight:600; margin-bottom:0.5rem;"></p>
<label>Nachricht (optional)</label>
<textarea id="searchJoinNachricht" placeholder="Warum möchtest du beitreten?"></textarea>
<p class="message error" id="searchJoinError" style="display:none; margin-top:0.75rem;"></p>
<div class="dialog-actions">
<button class="secondary" id="searchJoinCancelBtn">Abbrechen</button>
<button id="searchJoinSendBtn">Anfrage senden</button>
</div>
</div> </div>
</div> </div>
@@ -279,10 +337,12 @@
document.getElementById('grid' + cap(t)).innerHTML = ''; document.getElementById('grid' + cap(t)).innerHTML = '';
document.getElementById('more' + cap(t)).style.display = 'none'; document.getElementById('more' + cap(t)).style.display = 'none';
}); });
// Alle drei Typen parallel laden // Alle Typen parallel laden
loadChunk('users'); loadChunk('users');
loadChunk('locations'); loadChunk('locations');
loadChunk('events'); loadChunk('events');
loadGruppen(q);
loadHashtags(q);
} }
function clearAll() { function clearAll() {
@@ -295,6 +355,12 @@
document.getElementById('count' + cap(t)).textContent = '0'; document.getElementById('count' + cap(t)).textContent = '0';
document.getElementById('loading' + cap(t)).style.display = 'none'; document.getElementById('loading' + cap(t)).style.display = 'none';
}); });
document.getElementById('gridGruppen').innerHTML = '';
document.getElementById('countGruppen').textContent = '0';
document.getElementById('loadingGruppen').style.display = 'none';
document.getElementById('gridHashtags').innerHTML = '';
document.getElementById('countHashtags').textContent = '0';
document.getElementById('loadingHashtags').style.display = 'none';
const url = new URL(window.location); const url = new URL(window.location);
url.searchParams.delete('q'); url.searchParams.delete('q');
history.replaceState(null, '', url); history.replaceState(null, '', url);
@@ -374,14 +440,185 @@
}); });
} }
// ── Gruppen-Suche ──
async function loadGruppen(q) {
const loadingEl = document.getElementById('loadingGruppen');
const grid = document.getElementById('gridGruppen');
loadingEl.style.display = '';
grid.innerHTML = '';
try {
const res = await fetch('/gruppen/search?q=' + encodeURIComponent(q));
if (!res.ok) throw new Error();
const data = await res.json();
document.getElementById('countGruppen').textContent = data.length;
if (!data.length) {
grid.innerHTML = '<div class="search-empty" style="grid-column:1/-1;">Keine Ergebnisse.</div>';
return;
}
data.forEach(g => grid.appendChild(buildGruppeCard(g)));
} catch (e) {
// ignore
} finally {
loadingEl.style.display = 'none';
}
}
function buildGruppeCard(g) {
const card = document.createElement('div');
card.className = 'search-card';
card.style.cssText = 'justify-content:space-between; cursor:pointer;';
card.addEventListener('click', () => { location.href = '/community/gruppe.html?gruppeId=' + g.gruppeId; });
const av = g.bild
? `<div class="search-card-avatar search-card-avatar--square"><img src="data:image/jpeg;base64,${esc(g.bild)}" alt=""></div>`
: `<div class="search-card-avatar search-card-avatar--square">👥</div>`;
const privBadge = g.isPrivate ? ' 🔒' : '';
const sub = g.memberCount + ' Mitglied' + (g.memberCount !== 1 ? 'er' : '');
const info = document.createElement('div');
info.style.cssText = 'display:flex; align-items:center; gap:0.75rem; min-width:0; flex:1;';
info.innerHTML = `${av}<div class="search-card-body"><div class="search-card-name">${esc(g.name)}${privBadge}</div><div class="search-card-sub">${esc(sub)}</div></div>`;
card.appendChild(info);
if (!g.myRole) {
const btn = document.createElement('button');
btn.style.cssText = 'font-size:0.78rem; padding:0.3rem 0.65rem; width:auto; margin:0; white-space:nowrap; flex-shrink:0; margin-left:0.5rem;';
if (g.myRequestStatus === 'AUSSTEHEND') {
btn.disabled = true;
btn.style.opacity = '0.6';
btn.textContent = 'Anfrage ausstehend';
} else if (g.isPrivate) {
btn.textContent = 'Anfrage senden';
btn.addEventListener('click', e => { e.stopPropagation(); openSearchJoinDialog(g.gruppeId, g.name); });
} else {
btn.textContent = 'Beitreten';
btn.addEventListener('click', e => { e.stopPropagation(); joinGruppeSearch(g.gruppeId, btn); });
}
card.appendChild(btn);
}
return card;
}
// ── Hashtag-Suche ──
async function loadHashtags(q) {
const loadingEl = document.getElementById('loadingHashtags');
const grid = document.getElementById('gridHashtags');
loadingEl.style.display = '';
grid.innerHTML = '';
try {
const raw = q.startsWith('#') ? q.slice(1) : q;
const res = await fetch('/hashtags/suggest?q=' + encodeURIComponent(raw) + '&limit=20');
if (!res.ok) throw new Error();
const tags = await res.json();
document.getElementById('countHashtags').textContent = tags.length;
if (!tags.length) {
grid.innerHTML = '<div class="search-empty">Keine Hashtags gefunden.</div>';
return;
}
tags.forEach(tag => {
const a = document.createElement('a');
a.className = 'hashtag-chip';
a.href = '/community/feed.html?tag=' + encodeURIComponent(tag);
a.textContent = '#' + tag;
grid.appendChild(a);
});
} catch (e) {
// ignore
} finally {
loadingEl.style.display = 'none';
}
}
async function joinGruppeSearch(gruppeId, btn) {
btn.disabled = true;
btn.textContent = '…';
try {
const res = await fetch('/gruppen/' + gruppeId + '/join', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}'
});
if (res.ok || res.status === 201) {
btn.textContent = 'Beigetreten ✓';
} else {
btn.disabled = false;
btn.textContent = 'Beitreten';
}
} catch (e) {
btn.disabled = false;
btn.textContent = 'Beitreten';
}
}
// ── Join-Dialog für Gruppen ──
let searchJoinGruppeId = null;
function openSearchJoinDialog(gruppeId, name) {
searchJoinGruppeId = gruppeId;
document.getElementById('searchJoinGroupName').textContent = name;
document.getElementById('searchJoinNachricht').value = '';
document.getElementById('searchJoinError').style.display = 'none';
document.getElementById('searchJoinDialog').classList.add('visible');
}
function closeSearchJoinDialog() {
document.getElementById('searchJoinDialog').classList.remove('visible');
searchJoinGruppeId = null;
}
async function sendSearchJoinRequest() {
if (!searchJoinGruppeId) return;
document.getElementById('searchJoinError').style.display = 'none';
const nachricht = document.getElementById('searchJoinNachricht').value.trim() || null;
try {
const res = await fetch('/gruppen/' + searchJoinGruppeId + '/join', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nachricht })
});
if (res.ok || res.status === 201) {
closeSearchJoinDialog();
if (currentQuery.length >= 2) loadGruppen(currentQuery);
} else {
const el = document.getElementById('searchJoinError');
el.textContent = 'Fehler beim Senden der Anfrage.';
el.style.display = 'block';
}
} catch (e) {
const el = document.getElementById('searchJoinError');
el.textContent = 'Fehler: ' + e.message;
el.style.display = 'block';
}
}
document.getElementById('searchJoinCancelBtn').addEventListener('click', closeSearchJoinDialog);
document.getElementById('searchJoinSendBtn').addEventListener('click', sendSearchJoinRequest);
document.getElementById('searchJoinDialog').addEventListener('click', e => {
if (e.target === document.getElementById('searchJoinDialog')) closeSearchJoinDialog();
});
function cap(s) { return s.charAt(0).toUpperCase() + s.slice(1); } function cap(s) { return s.charAt(0).toUpperCase() + s.slice(1); }
// ── URL-Parameter beim Start auslesen ── // ── URL-Parameter beim Start auslesen ──
const initQ = new URLSearchParams(window.location.search).get('q') || ''; const params = new URLSearchParams(window.location.search);
const initQ = params.get('q') || '';
const initTab = params.get('tab') || '';
if (initTab) {
const tabBtn = document.querySelector(`.search-tab-btn[data-tab="${initTab}"]`);
if (tabBtn) tabBtn.click();
}
if (initQ.length >= 2) { if (initQ.length >= 2) {
input.value = initQ; input.value = initQ;
// Warten bis icons.js geladen ist
setTimeout(() => startSearch(initQ), 100); setTimeout(() => startSearch(initQ), 100);
} else if (initTab === 'hashtags') {
// Populäre Tags zeigen wenn kein Suchbegriff
setTimeout(() => loadHashtags(''), 100);
} }
})(); })();
</script> </script>

View File

@@ -243,6 +243,33 @@
.lb-comment-compose button { width:auto; margin:0; padding:0.35rem 0.75rem; font-size:0.8rem; } .lb-comment-compose button { width:auto; margin:0; padding:0.35rem 0.75rem; font-size:0.8rem; }
@media (max-width:650px) { .lb-layout { flex-direction:column; height:95vh; } .lb-post-side { border-right:none; border-bottom:1px solid var(--color-secondary); max-height:55vh; } .lb-comments-panel { width:100%; } } @media (max-width:650px) { .lb-layout { flex-direction:column; height:95vh; } .lb-post-side { border-right:none; border-bottom:1px solid var(--color-secondary); max-height:55vh; } .lb-comments-panel { width:100%; } }
/* ── Spiel starten ── */
.start-game-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 0.75rem;
}
.start-game-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.6rem;
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 12px;
padding: 1.25rem 1rem;
text-decoration: none;
color: var(--color-text);
transition: border-color 0.15s, background 0.15s;
text-align: center;
}
.start-game-card:hover {
border-color: var(--color-primary);
background: rgba(var(--color-primary-rgb,233,69,96),0.06);
}
.start-game-icon { font-size: 2rem; line-height: 1; }
.start-game-title { font-size: 0.9rem; font-weight: 600; }
/* ── Neue Mitglieder ── */ /* ── Neue Mitglieder ── */
.new-members-strip { .new-members-strip {
display: flex; display: flex;
@@ -306,6 +333,25 @@
<div class="active-game-list" id="activeLockList"></div> <div class="active-game-list" id="activeLockList"></div>
</div> </div>
<!-- Kein Spiel aktiv Starten -->
<div id="startGameSection" style="display:none;">
<div class="section-label">Spiel starten 🎮</div>
<div class="start-game-grid">
<a href="/games/vanilla/neuvanilla.html" class="start-game-card">
<div class="start-game-icon">🎭</div>
<div class="start-game-title">Vanilla Game starten</div>
</a>
<a href="/games/bdsm/neubdsm.html" class="start-game-card">
<div class="start-game-icon"></div>
<div class="start-game-title">BDSM-Game starten</div>
</a>
<a href="/games/chastity/neulock.html" class="start-game-card">
<div class="start-game-icon">🔒</div>
<div class="start-game-title">Chastity-Lock starten</div>
</a>
</div>
</div>
<!-- Einladungen --> <!-- Einladungen -->
<div id="invitesSection" style="display:none;"> <div id="invitesSection" style="display:none;">
<div class="section-label">Einladungen 📨</div> <div class="section-label">Einladungen 📨</div>
@@ -423,6 +469,7 @@
<script src="/js/shared.js"></script> <script src="/js/shared.js"></script>
<script src="/js/icons.js"></script> <script src="/js/icons.js"></script>
<script src="/js/hashtag.js"></script>
<script src="/js/nav.js"></script> <script src="/js/nav.js"></script>
<script> <script>
let myUserId = null; let myUserId = null;
@@ -436,14 +483,20 @@
if (user) { if (user) {
myUserId = user.userId; myUserId = user.userId;
document.getElementById('greeting').textContent = 'Willkommen zurück, ' + user.name + '!'; document.getElementById('greeting').textContent = 'Willkommen zurück, ' + user.name + '!';
loadActiveGames(user.userId); Promise.all([loadActiveGames(user.userId), loadActiveLock()]).then(() => {
loadActiveLock(); const hasGames = document.getElementById('activeGamesSection').style.display !== 'none';
const hasLock = document.getElementById('activeLockSection').style.display !== 'none';
if (!hasGames && !hasLock) {
document.getElementById('startGameSection').style.display = '';
}
});
loadInvites(); loadInvites();
loadFriendRequests(); loadFriendRequests();
loadVisitors(); loadVisitors();
loadMyEvents(); loadMyEvents();
loadLocEvents(); loadLocEvents();
loadFeed(); loadFeed();
attachHashtagAutocomplete(document.getElementById('homeComposeText'));
if (user.datingAktiv) { if (user.datingAktiv) {
loadWhoLikesMe(); loadWhoLikesMe();
loadMatches(); loadMatches();
@@ -931,7 +984,7 @@
<div class="post-meta">${fmtDate(p.createdAt)}${groupBadge}</div> <div class="post-meta">${fmtDate(p.createdAt)}${groupBadge}</div>
</div> </div>
</div> </div>
<div class="post-text">${esc(p.text || '')}</div> <div class="post-text">${renderTextWithHashtags(p.text || '')}</div>
${bildHtml} ${bildHtml}
${umfrageHtml} ${umfrageHtml}
<div class="post-actions"> <div class="post-actions">