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

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; }
.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 { 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; }
@@ -92,7 +97,13 @@
<div class="main">
<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" id="tabPublic" data-tab="public" onclick="switchTab('public', this)">Öffentlicher Feed</button>
</div>
@@ -140,6 +151,13 @@
<div class="sentinel" id="publicSentinel"></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>
@@ -165,37 +183,68 @@
<script src="/js/shared.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/meldung.js"></script>
<script src="/js/hashtag.js"></script>
<script>
// ── State ──
let myUserId = null;
let activeLbPostId = null;
let activeLbPostId = null;
let activeLbPostType = null;
let activeHashtag = null; // set when ?tag=... is in URL
const feedState = {
mine: { page:0, hasMore:true, loading:false, loaded:false },
public: { 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 },
hashtag: { page:0, hasMore:true, loading:false, loaded:false }
};
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 ──
fetch('/login/me').then(r => r.ok ? r.json() : null).then(async user => {
if (user) {
myUserId = user.userId;
const raw = sessionStorage.getItem('feedOpenPost');
if (raw) {
sessionStorage.removeItem('feedOpenPost');
loadFeed('mine');
openLbWithData(JSON.parse(raw));
if (activeHashtag) {
await loadFeed('hashtag');
} 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(() => {});
// ── 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 ──
function switchTab(name, btn) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
@@ -218,8 +267,14 @@
state.loading = true;
state.loaded = true;
try {
const endpoint = tab === 'mine' ? '/feed/mine' : '/feed/public';
const res = await fetch(`${endpoint}?page=${state.page}&size=10`);
let url;
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;
const data = await res.json();
const feedEl = document.getElementById(tab + 'Feed');
@@ -238,12 +293,14 @@
const observer = new IntersectionObserver(entries => {
entries.forEach(e => {
if (!e.isIntersecting) return;
if (e.target.id === 'mineSentinel') loadFeed('mine');
if (e.target.id === 'publicSentinel') loadFeed('public');
if (e.target.id === 'mineSentinel') loadFeed('mine');
if (e.target.id === 'publicSentinel') loadFeed('public');
if (e.target.id === 'hashtagSentinel') loadFeed('hashtag');
});
}, { threshold: 0.5 });
observer.observe(document.getElementById('mineSentinel'));
observer.observe(document.getElementById('publicSentinel'));
observer.observe(document.getElementById('hashtagSentinel'));
// bilderCarousel und carNav kommen aus shared.js
@@ -297,7 +354,7 @@
</div>
${deleteBtn}
</div>
<div class="post-text">${esc(p.text)}</div>
<div class="post-text">${renderTextWithHashtags(p.text)}</div>
${bildHtml}
${umfrageHtml}
<div class="post-actions">

View File

@@ -143,7 +143,7 @@
<!-- Friends tab -->
<div class="tab-panel active" id="tab-friends">
<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>
<!-- Pending tab -->

View File

@@ -297,8 +297,9 @@
<script src="/js/shared.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/hashtag.js"></script>
<script>
// ── Generic modal helpers ──
@@ -377,6 +378,9 @@
await loadGruppe();
await loadPosts();
const _composeText = document.getElementById('composeText');
if (_composeText) attachHashtagAutocomplete(_composeText);
const _savedTab = localStorage.getItem('tab_gruppe_' + gruppeId);
if (_savedTab) {
const _btn = document.querySelector(`.tab-btn[data-tab="${_savedTab}"]`);
@@ -488,7 +492,7 @@
}).join('');
body = bars + `<div class="umfrage-total">${total} Stimme${total !== 1 ? 'n' : ''} gesamt${p.multiChoice?' · Multi-Choice':''}</div>`;
} else {
body = `<div class="post-text">${esc(p.text)}</div>${bildHtml}`;
body = `<div class="post-text">${renderTextWithHashtags(p.text)}</div>${bildHtml}`;
}
return `
@@ -500,7 +504,7 @@
</div>
<div class="post-date">${fmtDate(p.createdAt)}</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}
<div class="post-actions">
<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-name { font-weight:700; font-size:1rem; }
.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.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-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; }
@@ -64,12 +57,14 @@
<div class="content">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:1.25rem;">
<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 class="tabs">
<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>
</div>
@@ -79,16 +74,6 @@
<p class="empty-hint" id="mineEmpty" style="display:none;">Du bist noch in keiner Gruppe.</p>
</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 -->
<div class="tab-panel" id="tab-requests">
<ul class="anfrage-list" id="requestsList"></ul>
@@ -121,20 +106,6 @@
</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/nav.js"></script>
@@ -157,7 +128,7 @@
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
? `<div class="gruppe-card-img"><img src="data:image/jpeg;base64,${g.bild}" alt=""></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>`
: '';
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 `
<div class="gruppe-card" id="gc-${g.gruppeId}" onclick="location.href='/community/gruppe.html?gruppeId=${g.gruppeId}'" style="cursor:pointer;">
${img}
@@ -183,7 +144,6 @@
<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>` : ''}
<div class="card-notif" id="notif-${g.gruppeId}"></div>
${actions ? `<div class="gruppe-card-actions">${actions}</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() {
try {
const reqRes = await fetch('/gruppen/requests/mine');
@@ -333,42 +262,6 @@
} 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 ──
function previewBild(input, previewId, dataId) {
@@ -401,9 +294,6 @@
document.getElementById('createDialog').addEventListener('click', e => {
if (e.target === document.getElementById('createDialog')) closeCreateDialog();
});
document.getElementById('joinDialog').addEventListener('click', e => {
if (e.target === document.getElementById('joinDialog')) closeJoinDialog();
});
loadMine();
</script>

View File

@@ -360,7 +360,7 @@
const list = document.getElementById('convList');
list.innerHTML = '';
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;
}
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);
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);
restoredFromSetup = true;
} else {
@@ -1273,14 +1274,15 @@
if (draft.gruppenJson) { sessionStorage.setItem('vanilla-session-gruppen', draft.gruppenJson); savedGruppen = new Set(JSON.parse(draft.gruppenJson)); }
}
}
const selfGeschlecht = user?.geschlecht || null;
const selfWerkzeuge = selfGeschlecht ? (WERKZEUGE_DEFAULTS[selfGeschlecht] || []) : [];
const defaults = await fetch('/user/me/bdsm-defaults').then(r => r.ok ? r.json() : {}).catch(() => ({}));
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);
if (selfId) {
if (user?.profilePicture) { playerProfilePics[selfId] = user.profilePicture; updatePlayerHeader(selfId, user.name); }
if (playerIds.length < MAX_PLAYERS) addPlayer();
restorePlayer(selfId, { geschlecht: selfGeschlecht, spieltMit: [], rollen: [], werkzeuge: selfWerkzeuge });
pruefeChastityConstraint(selfId, myUserId);
restorePlayer(selfId, { geschlecht: selfGeschlecht, spieltMit: defaults.spieltMit || [], rollen: defaults.rollen || [], werkzeuge: selfWerkzeuge });
if (myUserId) pruefeChastityConstraint(selfId, myUserId);
}
await ladeEinladungenAusDb(null);
}
@@ -1324,8 +1326,9 @@
if (user?.profilePicture) { playerProfilePics[guestOwnPlayerId] = user.profilePicture; updatePlayerHeader(guestOwnPlayerId, user?.name || ''); }
restorePlayer(guestOwnPlayerId, { geschlecht: user?.geschlecht || null, spieltMit: [], rollen: [], werkzeuge: [] });
pruefeChastityConstraint(guestOwnPlayerId, myUserId);
const defaults = await fetch('/user/me/bdsm-defaults').then(r => r.ok ? r.json() : {}).catch(() => ({}));
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-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);
z-index: 500;
align-items: center;
padding: 0 0.25rem;
padding: 0 4px 0 0.25rem;
}
.mobile-topbar-logo {
position: absolute;
@@ -43,9 +43,9 @@
background: none;
border: none;
color: var(--color-text);
font-size: 1.725rem;
font-size: 1.3rem;
line-height: 1;
padding: 0.75rem 0.675rem;
padding: 0.55rem 0.6rem;
cursor: pointer;
display: flex;
align-items: center;

View File

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

View File

@@ -5,12 +5,19 @@
// ── Bereichs-Definitionen ────────────────────────────────────────────────
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: {
prefixes: ['/community/'],
exclude: [
'/community/nachrichten.html',
'/community/benachrichtigungen.html',
'/community/einladungen.html',
],
items: [
{ href: '/community/feed.html', icon: 'FEED', label: 'Feed' },

View File

@@ -168,18 +168,26 @@
async function doSearch(q, overlay) {
try {
const res = await fetch('/search?q=' + encodeURIComponent(q) + '&limit=3');
if (!res.ok) { overlay.innerHTML = '<div class="topbar-search-hint">Fehler bei der Suche.</div>'; return; }
const data = await res.json();
const { users = [], locations = [], events = [] } = data;
const tagQuery = q.startsWith('#') ? q.slice(1) : q;
const [searchRes, gruppenRes, hashtagRes] = await Promise.all([
fetch('/search?q=' + encodeURIComponent(q) + '&limit=3'),
fetch('/gruppen/search?q=' + encodeURIComponent(q)),
fetch('/hashtags/suggest?q=' + encodeURIComponent(tagQuery) + '&limit=4')
]);
if (!users.length && !locations.length && !events.length) {
overlay.innerHTML = '<div class="topbar-search-hint">Keine Ergebnisse.</div>';
return;
}
const data = searchRes.ok ? await searchRes.json() : {};
const gruppen = gruppenRes.ok ? await gruppenRes.json() : [];
const hashtags = hashtagRes.ok ? await hashtagRes.json() : [];
const { users = [], locations = [], events = [] } = data;
const gruppenSlice = gruppen.slice(0, 3);
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) {
html += `<div class="topbar-search-section">Personen</div>`;
html += users.map(u => {
@@ -213,6 +221,26 @@
}).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>`;
overlay.innerHTML = html;
} catch (e) {

View File

@@ -163,6 +163,33 @@
transition: border-color 0.15s, color 0.15s;
}
.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>
</head>
<body class="app">
@@ -173,7 +200,7 @@
<div class="search-hero">
<div class="search-hero-input-wrap">
<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">
</div>
</div>
@@ -188,6 +215,12 @@
<button class="search-tab-btn" data-tab="events">
Veranstaltungen <span class="search-tab-count" id="countEvents">0</span>
</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 class="search-panel active" id="panel-users">
@@ -207,6 +240,31 @@
<div class="search-grid" id="gridEvents"></div>
<button class="search-load-more" id="moreEvents" style="display:none;">Mehr laden</button>
</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>
@@ -279,10 +337,12 @@
document.getElementById('grid' + cap(t)).innerHTML = '';
document.getElementById('more' + cap(t)).style.display = 'none';
});
// Alle drei Typen parallel laden
// Alle Typen parallel laden
loadChunk('users');
loadChunk('locations');
loadChunk('events');
loadGruppen(q);
loadHashtags(q);
}
function clearAll() {
@@ -295,6 +355,12 @@
document.getElementById('count' + cap(t)).textContent = '0';
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);
url.searchParams.delete('q');
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); }
// ── 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) {
input.value = initQ;
// Warten bis icons.js geladen ist
setTimeout(() => startSearch(initQ), 100);
} else if (initTab === 'hashtags') {
// Populäre Tags zeigen wenn kein Suchbegriff
setTimeout(() => loadHashtags(''), 100);
}
})();
</script>

View File

@@ -243,6 +243,33 @@
.lb-comment-compose button { width:auto; margin:0; padding:0.35rem 0.75rem; font-size:0.8rem; }
@media (max-width:650px) { .lb-layout { flex-direction:column; height:95vh; } .lb-post-side { border-right:none; border-bottom:1px solid var(--color-secondary); max-height:55vh; } .lb-comments-panel { width:100%; } }
/* ── Spiel starten ── */
.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 ── */
.new-members-strip {
display: flex;
@@ -306,6 +333,25 @@
<div class="active-game-list" id="activeLockList"></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 -->
<div id="invitesSection" style="display:none;">
<div class="section-label">Einladungen 📨</div>
@@ -423,6 +469,7 @@
<script src="/js/shared.js"></script>
<script src="/js/icons.js"></script>
<script src="/js/hashtag.js"></script>
<script src="/js/nav.js"></script>
<script>
let myUserId = null;
@@ -436,14 +483,20 @@
if (user) {
myUserId = user.userId;
document.getElementById('greeting').textContent = 'Willkommen zurück, ' + user.name + '!';
loadActiveGames(user.userId);
loadActiveLock();
Promise.all([loadActiveGames(user.userId), loadActiveLock()]).then(() => {
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();
loadFriendRequests();
loadVisitors();
loadMyEvents();
loadLocEvents();
loadFeed();
attachHashtagAutocomplete(document.getElementById('homeComposeText'));
if (user.datingAktiv) {
loadWhoLikesMe();
loadMatches();
@@ -931,7 +984,7 @@
<div class="post-meta">${fmtDate(p.createdAt)}${groupBadge}</div>
</div>
</div>
<div class="post-text">${esc(p.text || '')}</div>
<div class="post-text">${renderTextWithHashtags(p.text || '')}</div>
${bildHtml}
${umfrageHtml}
<div class="post-actions">