Hashtags eingeführt
Some checks failed
Host-Based Deploy (Java 21 Fix) / build-and-run (push) Has been cancelled
Some checks failed
Host-Based Deploy (Java 21 Fix) / build-and-run (push) Has been cancelled
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -4,3 +4,6 @@
|
||||
# Ignore Gradle build output directory
|
||||
build
|
||||
.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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
bin/main/de/oaa/xxx/hashtag/HashtagController.class
Normal file
BIN
bin/main/de/oaa/xxx/hashtag/HashtagController.class
Normal file
Binary file not shown.
BIN
bin/main/de/oaa/xxx/hashtag/HashtagEntity.class
Normal file
BIN
bin/main/de/oaa/xxx/hashtag/HashtagEntity.class
Normal file
Binary file not shown.
BIN
bin/main/de/oaa/xxx/hashtag/HashtagRepository.class
Normal file
BIN
bin/main/de/oaa/xxx/hashtag/HashtagRepository.class
Normal file
Binary file not shown.
BIN
bin/main/de/oaa/xxx/hashtag/HashtagService.class
Normal file
BIN
bin/main/de/oaa/xxx/hashtag/HashtagService.class
Normal file
Binary file not shown.
BIN
bin/main/de/oaa/xxx/hashtag/PostHashtagEntity.class
Normal file
BIN
bin/main/de/oaa/xxx/hashtag/PostHashtagEntity.class
Normal file
Binary file not shown.
Binary file not shown.
BIN
bin/main/de/oaa/xxx/hashtag/PostHashtagRepository.class
Normal file
BIN
bin/main/de/oaa/xxx/hashtag/PostHashtagRepository.class
Normal file
Binary file not shown.
@@ -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">
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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}">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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>
|
||||
371
bin/main/static/games/common/einladungen.html
Normal file
371
bin/main/static/games/common/einladungen.html
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// ── 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>
|
||||
@@ -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');
|
||||
|
||||
217
bin/main/static/js/hashtag.js
Normal file
217
bin/main/static/js/hashtag.js
Normal 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, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ── 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 });
|
||||
};
|
||||
})();
|
||||
@@ -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;
|
||||
|
||||
@@ -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/'])}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -9,7 +9,7 @@ services:
|
||||
MYSQL_USER: ${DB_USER}
|
||||
MYSQL_PASSWORD: ${DB_PASSWORD}
|
||||
ports:
|
||||
- "127.0.0.1:3306:3306"
|
||||
- "3306:3306"
|
||||
volumes:
|
||||
# Format: [Pfad auf dem Proxmox-Host]:[Pfad im Container]
|
||||
- /mnt/pve_nas/.mysql_data:/var/lib/mysql
|
||||
|
||||
@@ -54,7 +54,6 @@ public class SecurityConfig {
|
||||
.requestMatchers("/sessionbdsmingame.html").authenticated()
|
||||
.requestMatchers("/games/bdsm/neubdsm.html").authenticated()
|
||||
.requestMatchers("/games/bdsm/bdsmingame.html").authenticated()
|
||||
.requestMatchers("/community/personen-suchen.html").authenticated()
|
||||
.requestMatchers("/community/freunde.html").authenticated()
|
||||
.requestMatchers("/community/nachrichten.html").authenticated()
|
||||
.requestMatchers("/community/benutzer.html").authenticated()
|
||||
@@ -82,6 +81,7 @@ public class SecurityConfig {
|
||||
.requestMatchers("/community/event-detail.html").authenticated()
|
||||
.requestMatchers("/gruppen/**").authenticated()
|
||||
.requestMatchers("/feed/**").authenticated()
|
||||
.requestMatchers("/hashtags/**").authenticated()
|
||||
.requestMatchers("/notifications/**").authenticated()
|
||||
.requestMatchers("/events/**").authenticated()
|
||||
.requestMatchers("/*.html").permitAll()
|
||||
|
||||
@@ -5,7 +5,9 @@ import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
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.FeedPostRequest;
|
||||
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.FeedPostVoteEntity;
|
||||
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.gruppe.BeitragTyp;
|
||||
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.UmfrageStimmeEntity;
|
||||
import de.oaa.xxx.gruppe.repository.GruppeRepository;
|
||||
@@ -70,6 +75,7 @@ public class FeedController {
|
||||
private final UserRepository userRepository;
|
||||
private final UserService userService;
|
||||
private final LikeService likeService;
|
||||
private final HashtagService hashtagService;
|
||||
|
||||
public FeedController(FeedPostRepository feedPostRepository,
|
||||
FeedPostLikeRepository feedPostLikeRepository,
|
||||
@@ -85,7 +91,8 @@ public class FeedController {
|
||||
KommentarRepository kommentarRepository,
|
||||
UserRepository userRepository,
|
||||
UserService userService,
|
||||
LikeService likeService) {
|
||||
LikeService likeService,
|
||||
HashtagService hashtagService) {
|
||||
this.feedPostRepository = feedPostRepository;
|
||||
this.feedPostLikeRepository = feedPostLikeRepository;
|
||||
this.feedPostOptionRepository = feedPostOptionRepository;
|
||||
@@ -101,6 +108,7 @@ public class FeedController {
|
||||
this.userRepository = userRepository;
|
||||
this.userService = userService;
|
||||
this.likeService = likeService;
|
||||
this.hashtagService = hashtagService;
|
||||
}
|
||||
|
||||
record FeedPage(List<FeedItemDto> posts, boolean hasMore) {}
|
||||
@@ -131,6 +139,7 @@ public class FeedController {
|
||||
post.setPublic(req.isPublic());
|
||||
post.setCreatedAt(LocalDateTime.now());
|
||||
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());
|
||||
|
||||
if (typ == BeitragTyp.UMFRAGE && req.optionen() != null) {
|
||||
@@ -251,6 +260,55 @@ public class FeedController {
|
||||
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 ──
|
||||
|
||||
@PostMapping("/posts/{id}/like")
|
||||
@@ -316,6 +374,7 @@ public class FeedController {
|
||||
|
||||
if (!post.getAuthorId().equals(myId)) return ResponseEntity.status(403).build();
|
||||
|
||||
hashtagService.deleteForPost("FEED", id);
|
||||
feedPostVoteRepository.deleteByPostId(id);
|
||||
feedPostOptionRepository.deleteByPostId(id);
|
||||
feedPostLikeRepository.deleteByPostId(id);
|
||||
|
||||
@@ -3,6 +3,7 @@ package de.oaa.xxx.gruppe;
|
||||
import de.oaa.xxx.gruppe.dto.*;
|
||||
import de.oaa.xxx.gruppe.entity.*;
|
||||
import de.oaa.xxx.gruppe.repository.*;
|
||||
import de.oaa.xxx.hashtag.HashtagService;
|
||||
import de.oaa.xxx.social.LikeService;
|
||||
import de.oaa.xxx.social.repository.KommentarRepository;
|
||||
import de.oaa.xxx.user.UserEntity;
|
||||
@@ -35,6 +36,7 @@ public class GruppenbeitragController {
|
||||
private final UserRepository userRepository;
|
||||
private final UserService userService;
|
||||
private final LikeService likeService;
|
||||
private final HashtagService hashtagService;
|
||||
|
||||
public GruppenbeitragController(GruppeRepository gruppeRepository,
|
||||
GruppenmitgliedRepository mitgliedRepository,
|
||||
@@ -46,7 +48,8 @@ public class GruppenbeitragController {
|
||||
KommentarRepository kommentarRepository,
|
||||
UserRepository userRepository,
|
||||
UserService userService,
|
||||
LikeService likeService) {
|
||||
LikeService likeService,
|
||||
HashtagService hashtagService) {
|
||||
this.gruppeRepository = gruppeRepository;
|
||||
this.mitgliedRepository = mitgliedRepository;
|
||||
this.beitragRepository = beitragRepository;
|
||||
@@ -58,6 +61,7 @@ public class GruppenbeitragController {
|
||||
this.userRepository = userRepository;
|
||||
this.userService = userService;
|
||||
this.likeService = likeService;
|
||||
this.hashtagService = hashtagService;
|
||||
}
|
||||
|
||||
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.setCreatedAt(LocalDateTime.now());
|
||||
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);
|
||||
|
||||
if (typ == BeitragTyp.UMFRAGE && req.optionen() != null) {
|
||||
@@ -311,6 +316,7 @@ public class GruppenbeitragController {
|
||||
|
||||
private void deleteBeitragCascade(GruppenbeitragEntity beitrag) {
|
||||
UUID bid = beitrag.getBeitragId();
|
||||
hashtagService.deleteForPost("GROUP", bid);
|
||||
meldungRepository.deleteByBeitragId(bid);
|
||||
stimmeRepository.deleteByBeitragId(bid);
|
||||
optionRepository.deleteByBeitragId(bid);
|
||||
|
||||
35
src/main/java/de/oaa/xxx/hashtag/HashtagController.java
Normal file
35
src/main/java/de/oaa/xxx/hashtag/HashtagController.java
Normal 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));
|
||||
}
|
||||
}
|
||||
21
src/main/java/de/oaa/xxx/hashtag/HashtagEntity.java
Normal file
21
src/main/java/de/oaa/xxx/hashtag/HashtagEntity.java
Normal 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 #
|
||||
}
|
||||
15
src/main/java/de/oaa/xxx/hashtag/HashtagRepository.java
Normal file
15
src/main/java/de/oaa/xxx/hashtag/HashtagRepository.java
Normal 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);
|
||||
}
|
||||
100
src/main/java/de/oaa/xxx/hashtag/HashtagService.java
Normal file
100
src/main/java/de/oaa/xxx/hashtag/HashtagService.java
Normal 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();
|
||||
}
|
||||
}
|
||||
35
src/main/java/de/oaa/xxx/hashtag/PostHashtagEntity.java
Normal file
35
src/main/java/de/oaa/xxx/hashtag/PostHashtagEntity.java
Normal 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;
|
||||
}
|
||||
29
src/main/java/de/oaa/xxx/hashtag/PostHashtagRepository.java
Normal file
29
src/main/java/de/oaa/xxx/hashtag/PostHashtagRepository.java
Normal 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);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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}">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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>
|
||||
371
src/main/resources/static/games/common/einladungen.html
Normal file
371
src/main/resources/static/games/common/einladungen.html
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// ── 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>
|
||||
@@ -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');
|
||||
|
||||
217
src/main/resources/static/js/hashtag.js
Normal file
217
src/main/resources/static/js/hashtag.js
Normal 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, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ── 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 });
|
||||
};
|
||||
})();
|
||||
@@ -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;
|
||||
|
||||
@@ -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/'])}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user