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
|
# Ignore Gradle build output directory
|
||||||
build
|
build
|
||||||
.aider*
|
.aider*
|
||||||
|
|
||||||
|
# Secrets – niemals einchecken
|
||||||
|
.env
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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; }
|
.empty-hint { color:var(--color-muted); font-size:0.9rem; margin-top:0.5rem; }
|
||||||
.sentinel { height:1px; }
|
.sentinel { height:1px; }
|
||||||
|
|
||||||
|
.hashtag-banner { display:flex; align-items:center; gap:0.75rem; margin-bottom:1.25rem; padding:0.65rem 1rem; background:var(--color-card); border:1px solid var(--color-secondary); border-radius:8px; }
|
||||||
|
.hashtag-banner-tag { font-size:1.05rem; font-weight:700; color:var(--color-primary); }
|
||||||
|
.hashtag-banner-back { margin-left:auto; font-size:0.82rem; color:var(--color-muted); text-decoration:none; }
|
||||||
|
.hashtag-banner-back:hover { color:var(--color-primary); }
|
||||||
|
|
||||||
/* Lightbox */
|
/* Lightbox */
|
||||||
.lightbox { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.88); z-index:300; align-items:center; justify-content:center; }
|
.lightbox { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.88); z-index:300; align-items:center; justify-content:center; }
|
||||||
.lightbox.open { display:flex; }
|
.lightbox.open { display:flex; }
|
||||||
@@ -92,7 +97,13 @@
|
|||||||
<div class="main">
|
<div class="main">
|
||||||
<div class="content">
|
<div class="content">
|
||||||
|
|
||||||
<div class="tabs">
|
<!-- Hashtag-Banner (nur sichtbar wenn ?tag=… gesetzt) -->
|
||||||
|
<div class="hashtag-banner" id="hashtagBanner" style="display:none;">
|
||||||
|
<span>Posts mit </span><span class="hashtag-banner-tag" id="hashtagBannerLabel"></span>
|
||||||
|
<a class="hashtag-banner-back" href="/community/feed.html">× Zurück zum Feed</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tabs" id="feedTabs">
|
||||||
<button class="tab-btn active" id="tabMine" data-tab="mine" onclick="switchTab('mine', this)">Mein Feed</button>
|
<button class="tab-btn active" id="tabMine" data-tab="mine" onclick="switchTab('mine', this)">Mein Feed</button>
|
||||||
<button class="tab-btn" id="tabPublic" data-tab="public" onclick="switchTab('public', this)">Öffentlicher Feed</button>
|
<button class="tab-btn" id="tabPublic" data-tab="public" onclick="switchTab('public', this)">Öffentlicher Feed</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -140,6 +151,13 @@
|
|||||||
<div class="sentinel" id="publicSentinel"></div>
|
<div class="sentinel" id="publicSentinel"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Hashtag-Feed (wird angezeigt wenn ?tag=… gesetzt) -->
|
||||||
|
<div id="tab-hashtag" style="display:none;">
|
||||||
|
<div id="hashtagFeed"></div>
|
||||||
|
<p class="empty-hint" id="hashtagEmpty" style="display:none;">Keine Posts mit diesem Hashtag.</p>
|
||||||
|
<div class="sentinel" id="hashtagSentinel"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -165,37 +183,68 @@
|
|||||||
|
|
||||||
<script src="/js/shared.js"></script>
|
<script src="/js/shared.js"></script>
|
||||||
<script src="/js/icons.js"></script>
|
<script src="/js/icons.js"></script>
|
||||||
<script src="/js/nav.js"></script>
|
<script src="/js/nav.js"></script>
|
||||||
<script src="/js/social-sidebar.js"></script>
|
<script src="/js/social-sidebar.js"></script>
|
||||||
<script src="/js/meldung.js"></script>
|
<script src="/js/meldung.js"></script>
|
||||||
|
<script src="/js/hashtag.js"></script>
|
||||||
<script>
|
<script>
|
||||||
// ── State ──
|
// ── State ──
|
||||||
let myUserId = null;
|
let myUserId = null;
|
||||||
let activeLbPostId = null;
|
let activeLbPostId = null;
|
||||||
let activeLbPostType = null;
|
let activeLbPostType = null;
|
||||||
|
let activeHashtag = null; // set when ?tag=... is in URL
|
||||||
|
|
||||||
const feedState = {
|
const feedState = {
|
||||||
mine: { page:0, hasMore:true, loading:false, loaded:false },
|
mine: { page:0, hasMore:true, loading:false, loaded:false },
|
||||||
public: { page:0, hasMore:true, loading:false, loaded:false }
|
public: { page:0, hasMore:true, loading:false, loaded:false },
|
||||||
|
hashtag: { page:0, hasMore:true, loading:false, loaded:false }
|
||||||
};
|
};
|
||||||
|
|
||||||
let composeBilderArr = [];
|
let composeBilderArr = [];
|
||||||
|
|
||||||
|
// ── Hashtag-Modus prüfen ──
|
||||||
|
const _urlTag = new URLSearchParams(window.location.search).get('tag');
|
||||||
|
if (_urlTag) {
|
||||||
|
activeHashtag = _urlTag.replace(/^#/, '').toLowerCase();
|
||||||
|
document.getElementById('hashtagBanner').style.display = '';
|
||||||
|
document.getElementById('hashtagBannerLabel').textContent = '#' + activeHashtag;
|
||||||
|
document.getElementById('feedTabs').style.display = 'none';
|
||||||
|
document.getElementById('tab-mine').style.display = 'none';
|
||||||
|
document.getElementById('tab-public').style.display = 'none';
|
||||||
|
document.getElementById('tab-hashtag').style.display = '';
|
||||||
|
document.getElementById('compose').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
// ── Boot ──
|
// ── Boot ──
|
||||||
fetch('/login/me').then(r => r.ok ? r.json() : null).then(async user => {
|
fetch('/login/me').then(r => r.ok ? r.json() : null).then(async user => {
|
||||||
if (user) {
|
if (user) {
|
||||||
myUserId = user.userId;
|
myUserId = user.userId;
|
||||||
const raw = sessionStorage.getItem('feedOpenPost');
|
if (activeHashtag) {
|
||||||
if (raw) {
|
await loadFeed('hashtag');
|
||||||
sessionStorage.removeItem('feedOpenPost');
|
|
||||||
loadFeed('mine');
|
|
||||||
openLbWithData(JSON.parse(raw));
|
|
||||||
} else {
|
} else {
|
||||||
await loadFeed('mine');
|
const raw = sessionStorage.getItem('feedOpenPost');
|
||||||
|
if (raw) {
|
||||||
|
sessionStorage.removeItem('feedOpenPost');
|
||||||
|
loadFeed('mine');
|
||||||
|
openLbWithData(JSON.parse(raw));
|
||||||
|
} else {
|
||||||
|
await loadFeed('mine');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
|
||||||
|
// ── Autocomplete für Compose ──
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const ta = document.getElementById('composeText');
|
||||||
|
if (ta) attachHashtagAutocomplete(ta);
|
||||||
|
});
|
||||||
|
// Fallback falls DOMContentLoaded bereits gefeuert
|
||||||
|
if (document.readyState !== 'loading') {
|
||||||
|
const ta = document.getElementById('composeText');
|
||||||
|
if (ta) attachHashtagAutocomplete(ta);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Tab switching ──
|
// ── Tab switching ──
|
||||||
function switchTab(name, btn) {
|
function switchTab(name, btn) {
|
||||||
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||||||
@@ -218,8 +267,14 @@
|
|||||||
state.loading = true;
|
state.loading = true;
|
||||||
state.loaded = true;
|
state.loaded = true;
|
||||||
try {
|
try {
|
||||||
const endpoint = tab === 'mine' ? '/feed/mine' : '/feed/public';
|
let url;
|
||||||
const res = await fetch(`${endpoint}?page=${state.page}&size=10`);
|
if (tab === 'hashtag') {
|
||||||
|
url = `/feed/hashtag?tag=${encodeURIComponent(activeHashtag)}&page=${state.page}&size=10`;
|
||||||
|
} else {
|
||||||
|
const base = tab === 'mine' ? '/feed/mine' : '/feed/public';
|
||||||
|
url = `${base}?page=${state.page}&size=10`;
|
||||||
|
}
|
||||||
|
const res = await fetch(url);
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const feedEl = document.getElementById(tab + 'Feed');
|
const feedEl = document.getElementById(tab + 'Feed');
|
||||||
@@ -238,12 +293,14 @@
|
|||||||
const observer = new IntersectionObserver(entries => {
|
const observer = new IntersectionObserver(entries => {
|
||||||
entries.forEach(e => {
|
entries.forEach(e => {
|
||||||
if (!e.isIntersecting) return;
|
if (!e.isIntersecting) return;
|
||||||
if (e.target.id === 'mineSentinel') loadFeed('mine');
|
if (e.target.id === 'mineSentinel') loadFeed('mine');
|
||||||
if (e.target.id === 'publicSentinel') loadFeed('public');
|
if (e.target.id === 'publicSentinel') loadFeed('public');
|
||||||
|
if (e.target.id === 'hashtagSentinel') loadFeed('hashtag');
|
||||||
});
|
});
|
||||||
}, { threshold: 0.5 });
|
}, { threshold: 0.5 });
|
||||||
observer.observe(document.getElementById('mineSentinel'));
|
observer.observe(document.getElementById('mineSentinel'));
|
||||||
observer.observe(document.getElementById('publicSentinel'));
|
observer.observe(document.getElementById('publicSentinel'));
|
||||||
|
observer.observe(document.getElementById('hashtagSentinel'));
|
||||||
|
|
||||||
// bilderCarousel und carNav kommen aus shared.js
|
// bilderCarousel und carNav kommen aus shared.js
|
||||||
|
|
||||||
@@ -297,7 +354,7 @@
|
|||||||
</div>
|
</div>
|
||||||
${deleteBtn}
|
${deleteBtn}
|
||||||
</div>
|
</div>
|
||||||
<div class="post-text">${esc(p.text)}</div>
|
<div class="post-text">${renderTextWithHashtags(p.text)}</div>
|
||||||
${bildHtml}
|
${bildHtml}
|
||||||
${umfrageHtml}
|
${umfrageHtml}
|
||||||
<div class="post-actions">
|
<div class="post-actions">
|
||||||
|
|||||||
@@ -143,7 +143,7 @@
|
|||||||
<!-- Friends tab -->
|
<!-- Friends tab -->
|
||||||
<div class="tab-panel active" id="tab-friends">
|
<div class="tab-panel active" id="tab-friends">
|
||||||
<ul class="user-list" id="friendsList"></ul>
|
<ul class="user-list" id="friendsList"></ul>
|
||||||
<p class="empty-hint" id="friendsEmpty" style="display:none;">Du hast noch keine Freunde. <a href="/community/personen-suchen.html" style="color:var(--color-primary);">Personen suchen</a></p>
|
<p class="empty-hint" id="friendsEmpty" style="display:none;">Du hast noch keine Freunde. <a href="/search.html" style="color:var(--color-primary);">Personen suchen</a></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pending tab -->
|
<!-- Pending tab -->
|
||||||
|
|||||||
@@ -297,8 +297,9 @@
|
|||||||
|
|
||||||
<script src="/js/shared.js"></script>
|
<script src="/js/shared.js"></script>
|
||||||
<script src="/js/icons.js"></script>
|
<script src="/js/icons.js"></script>
|
||||||
<script src="/js/nav.js"></script>
|
<script src="/js/nav.js"></script>
|
||||||
<script src="/js/social-sidebar.js"></script>
|
<script src="/js/social-sidebar.js"></script>
|
||||||
|
<script src="/js/hashtag.js"></script>
|
||||||
<script>
|
<script>
|
||||||
// ── Generic modal helpers ──
|
// ── Generic modal helpers ──
|
||||||
|
|
||||||
@@ -377,6 +378,9 @@
|
|||||||
await loadGruppe();
|
await loadGruppe();
|
||||||
await loadPosts();
|
await loadPosts();
|
||||||
|
|
||||||
|
const _composeText = document.getElementById('composeText');
|
||||||
|
if (_composeText) attachHashtagAutocomplete(_composeText);
|
||||||
|
|
||||||
const _savedTab = localStorage.getItem('tab_gruppe_' + gruppeId);
|
const _savedTab = localStorage.getItem('tab_gruppe_' + gruppeId);
|
||||||
if (_savedTab) {
|
if (_savedTab) {
|
||||||
const _btn = document.querySelector(`.tab-btn[data-tab="${_savedTab}"]`);
|
const _btn = document.querySelector(`.tab-btn[data-tab="${_savedTab}"]`);
|
||||||
@@ -488,7 +492,7 @@
|
|||||||
}).join('');
|
}).join('');
|
||||||
body = bars + `<div class="umfrage-total">${total} Stimme${total !== 1 ? 'n' : ''} gesamt${p.multiChoice?' · Multi-Choice':''}</div>`;
|
body = bars + `<div class="umfrage-total">${total} Stimme${total !== 1 ? 'n' : ''} gesamt${p.multiChoice?' · Multi-Choice':''}</div>`;
|
||||||
} else {
|
} else {
|
||||||
body = `<div class="post-text">${esc(p.text)}</div>${bildHtml}`;
|
body = `<div class="post-text">${renderTextWithHashtags(p.text)}</div>${bildHtml}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `
|
return `
|
||||||
@@ -500,7 +504,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="post-date">${fmtDate(p.createdAt)}</div>
|
<div class="post-date">${fmtDate(p.createdAt)}</div>
|
||||||
</div>
|
</div>
|
||||||
${p.beitragTyp === 'UMFRAGE' ? `<div style="font-weight:600;margin-bottom:0.5rem;">${esc(p.text)}</div>${bildHtml}` : ''}
|
${p.beitragTyp === 'UMFRAGE' ? `<div style="font-weight:600;margin-bottom:0.5rem;">${renderTextWithHashtags(p.text)}</div>${bildHtml}` : ''}
|
||||||
${body}
|
${body}
|
||||||
<div class="post-actions">
|
<div class="post-actions">
|
||||||
<button class="post-action-btn ${p.likedByMe?'active':''}" onclick="event.stopPropagation(); toggleLike('${p.beitragId}',this)" id="like-btn-${p.beitragId}">
|
<button class="post-action-btn ${p.likedByMe?'active':''}" onclick="event.stopPropagation(); toggleLike('${p.beitragId}',this)" id="like-btn-${p.beitragId}">
|
||||||
|
|||||||
@@ -22,16 +22,9 @@
|
|||||||
.gruppe-card-body { padding:0.85rem; flex:1; display:flex; flex-direction:column; gap:0.4rem; }
|
.gruppe-card-body { padding:0.85rem; flex:1; display:flex; flex-direction:column; gap:0.4rem; }
|
||||||
.gruppe-card-name { font-weight:700; font-size:1rem; }
|
.gruppe-card-name { font-weight:700; font-size:1rem; }
|
||||||
.gruppe-card-meta { font-size:0.78rem; color:var(--color-muted); }
|
.gruppe-card-meta { font-size:0.78rem; color:var(--color-muted); }
|
||||||
.gruppe-card-actions { display:flex; gap:0.5rem; flex-wrap:wrap; margin-top:auto; padding-top:0.5rem; }
|
|
||||||
.gruppe-card-actions button, .gruppe-card-actions a.btn { margin-top:0; padding:0.3rem 0.7rem; font-size:0.8rem; width:auto; }
|
|
||||||
|
|
||||||
.role-badge { font-size:0.7rem; font-weight:700; padding:0.1rem 0.4rem; border-radius:4px; background:var(--color-primary); color:#fff; display:inline-block; }
|
.role-badge { font-size:0.7rem; font-weight:700; padding:0.1rem 0.4rem; border-radius:4px; background:var(--color-primary); color:#fff; display:inline-block; }
|
||||||
.role-badge.mitglied { background:var(--color-secondary); color:var(--color-text); }
|
.role-badge.mitglied { background:var(--color-secondary); color:var(--color-text); }
|
||||||
|
|
||||||
.search-row { display:flex; gap:0.75rem; margin-bottom:1rem; }
|
|
||||||
.search-row input { flex:1; }
|
|
||||||
.search-row button { white-space:nowrap; width:auto; margin-top:0; }
|
|
||||||
|
|
||||||
.anfrage-list { list-style:none; margin:0; padding:0; }
|
.anfrage-list { list-style:none; margin:0; padding:0; }
|
||||||
.anfrage-item { display:flex; align-items:center; justify-content:space-between; gap:1rem; padding:0.75rem 0; border-bottom:1px solid var(--color-secondary); }
|
.anfrage-item { display:flex; align-items:center; justify-content:space-between; gap:1rem; padding:0.75rem 0; border-bottom:1px solid var(--color-secondary); }
|
||||||
.anfrage-item:last-child { border-bottom:none; }
|
.anfrage-item:last-child { border-bottom:none; }
|
||||||
@@ -64,12 +57,14 @@
|
|||||||
<div class="content">
|
<div class="content">
|
||||||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:1.25rem;">
|
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:1.25rem;">
|
||||||
<h1 style="margin:0;">Gruppen</h1>
|
<h1 style="margin:0;">Gruppen</h1>
|
||||||
<button onclick="openCreateDialog()" style="width:auto; padding:0.4rem 1rem; font-size:0.9rem; margin:0;">+ Erstellen</button>
|
<div style="display:flex; gap:0.5rem;">
|
||||||
|
<button onclick="location.href='/search.html?tab=gruppen'" style="width:auto; padding:0.4rem 1rem; font-size:0.9rem; margin:0;">🔍 Suchen</button>
|
||||||
|
<button onclick="openCreateDialog()" style="width:auto; padding:0.4rem 1rem; font-size:0.9rem; margin:0;">+ Erstellen</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<button class="tab-btn active" data-tab="mine" onclick="switchTab('mine', this)">Meine Gruppen</button>
|
<button class="tab-btn active" data-tab="mine" onclick="switchTab('mine', this)">Meine Gruppen</button>
|
||||||
<button class="tab-btn" data-tab="discover" onclick="switchTab('discover', this)">Entdecken</button>
|
|
||||||
<button class="tab-btn" data-tab="requests" onclick="switchTab('requests', this)">Meine Anfragen</button>
|
<button class="tab-btn" data-tab="requests" onclick="switchTab('requests', this)">Meine Anfragen</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -79,16 +74,6 @@
|
|||||||
<p class="empty-hint" id="mineEmpty" style="display:none;">Du bist noch in keiner Gruppe.</p>
|
<p class="empty-hint" id="mineEmpty" style="display:none;">Du bist noch in keiner Gruppe.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Entdecken -->
|
|
||||||
<div class="tab-panel" id="tab-discover">
|
|
||||||
<div class="search-row">
|
|
||||||
<input type="text" id="searchInput" placeholder="Gruppenname suchen…" onkeydown="if(event.key==='Enter')doSearch()">
|
|
||||||
<button onclick="doSearch()">Suchen</button>
|
|
||||||
</div>
|
|
||||||
<div class="gruppe-grid" id="discoverGrid"></div>
|
|
||||||
<p class="empty-hint" id="discoverHint">Gib einen Suchbegriff ein.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Meine Anfragen -->
|
<!-- Meine Anfragen -->
|
||||||
<div class="tab-panel" id="tab-requests">
|
<div class="tab-panel" id="tab-requests">
|
||||||
<ul class="anfrage-list" id="requestsList"></ul>
|
<ul class="anfrage-list" id="requestsList"></ul>
|
||||||
@@ -121,20 +106,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Beitrittsanfrage Dialog -->
|
|
||||||
<div class="dialog-backdrop" id="joinDialog">
|
|
||||||
<div class="dialog">
|
|
||||||
<h3>Beitrittsanfrage senden</h3>
|
|
||||||
<p id="joinDialogGroupName" style="font-weight:600; margin-bottom:0.5rem;"></p>
|
|
||||||
<label>Nachricht (optional)</label>
|
|
||||||
<textarea id="joinNachricht" placeholder="Warum möchtest du beitreten?"></textarea>
|
|
||||||
<p class="message error" id="joinError" style="display:none; margin-top:0.75rem;"></p>
|
|
||||||
<div class="dialog-actions">
|
|
||||||
<button class="secondary" onclick="closeJoinDialog()">Abbrechen</button>
|
|
||||||
<button onclick="sendJoinRequest()">Anfrage senden</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="/js/icons.js"></script>
|
<script src="/js/icons.js"></script>
|
||||||
<script src="/js/nav.js"></script>
|
<script src="/js/nav.js"></script>
|
||||||
@@ -157,7 +128,7 @@
|
|||||||
|
|
||||||
function esc(s) { const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; }
|
function esc(s) { const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; }
|
||||||
|
|
||||||
function gruppeCard(g, showJoin = false) {
|
function gruppeCard(g) {
|
||||||
const img = g.bild
|
const img = g.bild
|
||||||
? `<div class="gruppe-card-img"><img src="data:image/jpeg;base64,${g.bild}" alt=""></div>`
|
? `<div class="gruppe-card-img"><img src="data:image/jpeg;base64,${g.bild}" alt=""></div>`
|
||||||
: `<div class="gruppe-card-img">👥</div>`;
|
: `<div class="gruppe-card-img">👥</div>`;
|
||||||
@@ -165,16 +136,6 @@
|
|||||||
? `<span class="role-badge ${g.myRole === 'ADMIN' ? '' : 'mitglied'}">${g.myRole === 'ADMIN' ? 'Admin' : 'Mitglied'}</span>`
|
? `<span class="role-badge ${g.myRole === 'ADMIN' ? '' : 'mitglied'}">${g.myRole === 'ADMIN' ? 'Admin' : 'Mitglied'}</span>`
|
||||||
: '';
|
: '';
|
||||||
const privBadge = g.isPrivate ? ' 🔒' : '';
|
const privBadge = g.isPrivate ? ' 🔒' : '';
|
||||||
let actions = '';
|
|
||||||
if (showJoin && !g.myRole) {
|
|
||||||
if (g.myRequestStatus === 'AUSSTEHEND') {
|
|
||||||
actions = `<button disabled style="opacity:0.6;" onclick="event.stopPropagation()">Anfrage ausstehend</button>`;
|
|
||||||
} else if (g.isPrivate) {
|
|
||||||
actions = `<button onclick="event.stopPropagation(); openJoinDialog('${g.gruppeId}','${esc(g.name)}')">Anfrage senden</button>`;
|
|
||||||
} else {
|
|
||||||
actions = `<button onclick="event.stopPropagation(); joinGruppe('${g.gruppeId}', this)">Beitreten</button>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return `
|
return `
|
||||||
<div class="gruppe-card" id="gc-${g.gruppeId}" onclick="location.href='/community/gruppe.html?gruppeId=${g.gruppeId}'" style="cursor:pointer;">
|
<div class="gruppe-card" id="gc-${g.gruppeId}" onclick="location.href='/community/gruppe.html?gruppeId=${g.gruppeId}'" style="cursor:pointer;">
|
||||||
${img}
|
${img}
|
||||||
@@ -183,7 +144,6 @@
|
|||||||
<div class="gruppe-card-meta">${g.memberCount} Mitglied${g.memberCount !== 1 ? 'er' : ''} · ${g.postCount} Beiträge</div>
|
<div class="gruppe-card-meta">${g.memberCount} Mitglied${g.memberCount !== 1 ? 'er' : ''} · ${g.postCount} Beiträge</div>
|
||||||
${g.beschreibung ? `<div style="font-size:0.82rem;color:var(--color-muted);">${esc(g.beschreibung.substring(0,80))}${g.beschreibung.length>80?'…':''}</div>` : ''}
|
${g.beschreibung ? `<div style="font-size:0.82rem;color:var(--color-muted);">${esc(g.beschreibung.substring(0,80))}${g.beschreibung.length>80?'…':''}</div>` : ''}
|
||||||
<div class="card-notif" id="notif-${g.gruppeId}"></div>
|
<div class="card-notif" id="notif-${g.gruppeId}"></div>
|
||||||
${actions ? `<div class="gruppe-card-actions">${actions}</div>` : ''}
|
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -223,37 +183,6 @@
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doSearch() {
|
|
||||||
const q = document.getElementById('searchInput').value.trim();
|
|
||||||
if (!q) return;
|
|
||||||
try {
|
|
||||||
const res = await fetch('/gruppen/search?q=' + encodeURIComponent(q));
|
|
||||||
if (!res.ok) return;
|
|
||||||
const data = await res.json();
|
|
||||||
const grid = document.getElementById('discoverGrid');
|
|
||||||
const hint = document.getElementById('discoverHint');
|
|
||||||
grid.innerHTML = '';
|
|
||||||
if (data.length === 0) { hint.textContent = 'Keine Gruppen gefunden.'; hint.style.display = ''; return; }
|
|
||||||
hint.style.display = 'none';
|
|
||||||
data.forEach(g => grid.insertAdjacentHTML('beforeend', gruppeCard(g, true)));
|
|
||||||
} catch(e) { console.error(e); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function joinGruppe(gruppeId, btn) {
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.textContent = '…';
|
|
||||||
try {
|
|
||||||
const res = await fetch('/gruppen/' + gruppeId + '/join', { method: 'POST', headers:{'Content-Type':'application/json'}, body:'{}' });
|
|
||||||
if (res.ok || res.status === 201) {
|
|
||||||
btn.textContent = 'Beigetreten ✓';
|
|
||||||
setTimeout(loadMine, 500);
|
|
||||||
} else {
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = 'Beitreten';
|
|
||||||
}
|
|
||||||
} catch(e) { btn.disabled = false; btn.textContent = 'Beitreten'; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadRequests() {
|
async function loadRequests() {
|
||||||
try {
|
try {
|
||||||
const reqRes = await fetch('/gruppen/requests/mine');
|
const reqRes = await fetch('/gruppen/requests/mine');
|
||||||
@@ -333,42 +262,6 @@
|
|||||||
} catch(e) { showCreateError('Fehler: ' + e.message); }
|
} catch(e) { showCreateError('Fehler: ' + e.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Join dialog ──
|
|
||||||
|
|
||||||
let pendingJoinGruppeId = null;
|
|
||||||
function openJoinDialog(gruppeId, name) {
|
|
||||||
pendingJoinGruppeId = gruppeId;
|
|
||||||
document.getElementById('joinDialogGroupName').textContent = name;
|
|
||||||
document.getElementById('joinNachricht').value = '';
|
|
||||||
document.getElementById('joinDialog').classList.add('visible');
|
|
||||||
}
|
|
||||||
function closeJoinDialog() { document.getElementById('joinDialog').classList.remove('visible'); pendingJoinGruppeId = null; }
|
|
||||||
|
|
||||||
async function sendJoinRequest() {
|
|
||||||
if (!pendingJoinGruppeId) return;
|
|
||||||
document.getElementById('joinError').style.display = 'none';
|
|
||||||
const nachricht = document.getElementById('joinNachricht').value.trim() || null;
|
|
||||||
try {
|
|
||||||
const res = await fetch('/gruppen/' + pendingJoinGruppeId + '/join', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type':'application/json'},
|
|
||||||
body: JSON.stringify({ nachricht })
|
|
||||||
});
|
|
||||||
if (res.ok || res.status === 201) {
|
|
||||||
closeJoinDialog();
|
|
||||||
doSearch();
|
|
||||||
} else {
|
|
||||||
const el = document.getElementById('joinError');
|
|
||||||
el.textContent = 'Fehler beim Senden der Anfrage.';
|
|
||||||
el.style.display = 'block';
|
|
||||||
}
|
|
||||||
} catch(e) {
|
|
||||||
const el = document.getElementById('joinError');
|
|
||||||
el.textContent = 'Fehler: ' + e.message;
|
|
||||||
el.style.display = 'block';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Image preview ──
|
// ── Image preview ──
|
||||||
|
|
||||||
function previewBild(input, previewId, dataId) {
|
function previewBild(input, previewId, dataId) {
|
||||||
@@ -401,9 +294,6 @@
|
|||||||
document.getElementById('createDialog').addEventListener('click', e => {
|
document.getElementById('createDialog').addEventListener('click', e => {
|
||||||
if (e.target === document.getElementById('createDialog')) closeCreateDialog();
|
if (e.target === document.getElementById('createDialog')) closeCreateDialog();
|
||||||
});
|
});
|
||||||
document.getElementById('joinDialog').addEventListener('click', e => {
|
|
||||||
if (e.target === document.getElementById('joinDialog')) closeJoinDialog();
|
|
||||||
});
|
|
||||||
|
|
||||||
loadMine();
|
loadMine();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -360,7 +360,7 @@
|
|||||||
const list = document.getElementById('convList');
|
const list = document.getElementById('convList');
|
||||||
list.innerHTML = '';
|
list.innerHTML = '';
|
||||||
if (convs.length === 0) {
|
if (convs.length === 0) {
|
||||||
list.innerHTML = '<li style="padding:1rem; color:var(--color-muted); font-size:0.9rem;">Noch keine Nachrichten. <a href="/community/personen-suchen.html" style="color:var(--color-primary);">Personen suchen</a></li>';
|
list.innerHTML = '<li style="padding:1rem; color:var(--color-muted); font-size:0.9rem;">Noch keine Nachrichten. <a href="/search.html" style="color:var(--color-primary);">Personen suchen</a></li>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
convs.forEach(c => {
|
convs.forEach(c => {
|
||||||
|
|||||||
@@ -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);
|
const id = addPlayer(p.name, i === 0, i === 0, false, false);
|
||||||
if (id) { restorePlayer(id, p); if (p.userId) userIdToInfo[p.userId] = { playerId: id, name: p.name }; }
|
if (id) { restorePlayer(id, p); if (p.userId) userIdToInfo[p.userId] = { playerId: id, name: p.name }; }
|
||||||
});
|
});
|
||||||
|
Object.entries(userIdToInfo).forEach(([userId, info]) => { pruefeChastityConstraint(info.playerId, userId); });
|
||||||
await ladeEinladungenAusDb(userIdToInfo);
|
await ladeEinladungenAusDb(userIdToInfo);
|
||||||
restoredFromSetup = true;
|
restoredFromSetup = true;
|
||||||
} else {
|
} else {
|
||||||
@@ -1273,14 +1274,15 @@
|
|||||||
if (draft.gruppenJson) { sessionStorage.setItem('vanilla-session-gruppen', draft.gruppenJson); savedGruppen = new Set(JSON.parse(draft.gruppenJson)); }
|
if (draft.gruppenJson) { sessionStorage.setItem('vanilla-session-gruppen', draft.gruppenJson); savedGruppen = new Set(JSON.parse(draft.gruppenJson)); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const selfGeschlecht = user?.geschlecht || null;
|
const defaults = await fetch('/user/me/bdsm-defaults').then(r => r.ok ? r.json() : {}).catch(() => ({}));
|
||||||
const selfWerkzeuge = selfGeschlecht ? (WERKZEUGE_DEFAULTS[selfGeschlecht] || []) : [];
|
const selfGeschlecht = defaults.geschlecht || user?.geschlecht || null;
|
||||||
|
const selfWerkzeuge = defaults.werkzeuge?.length ? defaults.werkzeuge : (selfGeschlecht ? (WERKZEUGE_DEFAULTS[selfGeschlecht] || []) : []);
|
||||||
const selfId = addPlayer(user ? user.name : '', true, true, !!selfGeschlecht, false);
|
const selfId = addPlayer(user ? user.name : '', true, true, !!selfGeschlecht, false);
|
||||||
if (selfId) {
|
if (selfId) {
|
||||||
if (user?.profilePicture) { playerProfilePics[selfId] = user.profilePicture; updatePlayerHeader(selfId, user.name); }
|
if (user?.profilePicture) { playerProfilePics[selfId] = user.profilePicture; updatePlayerHeader(selfId, user.name); }
|
||||||
if (playerIds.length < MAX_PLAYERS) addPlayer();
|
if (playerIds.length < MAX_PLAYERS) addPlayer();
|
||||||
restorePlayer(selfId, { geschlecht: selfGeschlecht, spieltMit: [], rollen: [], werkzeuge: selfWerkzeuge });
|
restorePlayer(selfId, { geschlecht: selfGeschlecht, spieltMit: defaults.spieltMit || [], rollen: defaults.rollen || [], werkzeuge: selfWerkzeuge });
|
||||||
pruefeChastityConstraint(selfId, myUserId);
|
if (myUserId) pruefeChastityConstraint(selfId, myUserId);
|
||||||
}
|
}
|
||||||
await ladeEinladungenAusDb(null);
|
await ladeEinladungenAusDb(null);
|
||||||
}
|
}
|
||||||
@@ -1324,8 +1326,9 @@
|
|||||||
|
|
||||||
if (user?.profilePicture) { playerProfilePics[guestOwnPlayerId] = user.profilePicture; updatePlayerHeader(guestOwnPlayerId, user?.name || ''); }
|
if (user?.profilePicture) { playerProfilePics[guestOwnPlayerId] = user.profilePicture; updatePlayerHeader(guestOwnPlayerId, user?.name || ''); }
|
||||||
|
|
||||||
restorePlayer(guestOwnPlayerId, { geschlecht: user?.geschlecht || null, spieltMit: [], rollen: [], werkzeuge: [] });
|
const defaults = await fetch('/user/me/bdsm-defaults').then(r => r.ok ? r.json() : {}).catch(() => ({}));
|
||||||
pruefeChastityConstraint(guestOwnPlayerId, myUserId);
|
restorePlayer(guestOwnPlayerId, { geschlecht: defaults.geschlecht || user?.geschlecht || null, spieltMit: defaults.spieltMit || [], rollen: defaults.rollen || [], werkzeuge: defaults.werkzeuge || [] });
|
||||||
|
if (myUserId) pruefeChastityConstraint(guestOwnPlayerId, myUserId);
|
||||||
|
|
||||||
document.getElementById('acc-grundeinstellungen-btn').classList.add('is-open');
|
document.getElementById('acc-grundeinstellungen-btn').classList.add('is-open');
|
||||||
document.getElementById('acc-grundeinstellungen-body').classList.add('is-open');
|
document.getElementById('acc-grundeinstellungen-body').classList.add('is-open');
|
||||||
|
|||||||
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);
|
box-shadow: 0 2px 12px rgba(0,0,0,0.4);
|
||||||
z-index: 500;
|
z-index: 500;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 0.25rem;
|
padding: 0 4px 0 0.25rem;
|
||||||
}
|
}
|
||||||
.mobile-topbar-logo {
|
.mobile-topbar-logo {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -43,9 +43,9 @@
|
|||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
font-size: 1.725rem;
|
font-size: 1.3rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
padding: 0.75rem 0.675rem;
|
padding: 0.55rem 0.6rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -301,7 +301,7 @@
|
|||||||
'/community/gruppen.html', '/community/gruppe.html',
|
'/community/gruppen.html', '/community/gruppe.html',
|
||||||
'/community/locations.html', '/community/location-detail.html',
|
'/community/locations.html', '/community/location-detail.html',
|
||||||
'/community/events.html', '/community/event-detail.html',
|
'/community/events.html', '/community/event-detail.html',
|
||||||
'/community/abonnements.html', '/community/personen-suchen.html',
|
'/community/abonnements.html',
|
||||||
'/community/benutzer.html',
|
'/community/benutzer.html',
|
||||||
])}
|
])}
|
||||||
${column('colDating', 'Dating', col3Html, ['/dating/'])}
|
${column('colDating', 'Dating', col3Html, ['/dating/'])}
|
||||||
|
|||||||
@@ -5,12 +5,19 @@
|
|||||||
|
|
||||||
// ── Bereichs-Definitionen ────────────────────────────────────────────────
|
// ── Bereichs-Definitionen ────────────────────────────────────────────────
|
||||||
const SECTIONS = {
|
const SECTIONS = {
|
||||||
|
common: {
|
||||||
|
prefixes: ['/games/common/'],
|
||||||
|
items: [
|
||||||
|
{ href: '/games/vanilla/neuvanilla.html', icon: 'VANILLA', label: 'Vanilla Game' },
|
||||||
|
{ href: '/games/bdsm/neubdsm.html', icon: 'BDSM', label: 'BDSM Game' },
|
||||||
|
{ href: '/games/chastity/neulock.html', icon: 'CHASTITY', label: 'Chastity Game' },
|
||||||
|
],
|
||||||
|
},
|
||||||
social: {
|
social: {
|
||||||
prefixes: ['/community/'],
|
prefixes: ['/community/'],
|
||||||
exclude: [
|
exclude: [
|
||||||
'/community/nachrichten.html',
|
'/community/nachrichten.html',
|
||||||
'/community/benachrichtigungen.html',
|
'/community/benachrichtigungen.html',
|
||||||
'/community/einladungen.html',
|
|
||||||
],
|
],
|
||||||
items: [
|
items: [
|
||||||
{ href: '/community/feed.html', icon: 'FEED', label: 'Feed' },
|
{ href: '/community/feed.html', icon: 'FEED', label: 'Feed' },
|
||||||
|
|||||||
@@ -168,18 +168,26 @@
|
|||||||
|
|
||||||
async function doSearch(q, overlay) {
|
async function doSearch(q, overlay) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/search?q=' + encodeURIComponent(q) + '&limit=3');
|
const tagQuery = q.startsWith('#') ? q.slice(1) : q;
|
||||||
if (!res.ok) { overlay.innerHTML = '<div class="topbar-search-hint">Fehler bei der Suche.</div>'; return; }
|
const [searchRes, gruppenRes, hashtagRes] = await Promise.all([
|
||||||
const data = await res.json();
|
fetch('/search?q=' + encodeURIComponent(q) + '&limit=3'),
|
||||||
const { users = [], locations = [], events = [] } = data;
|
fetch('/gruppen/search?q=' + encodeURIComponent(q)),
|
||||||
|
fetch('/hashtags/suggest?q=' + encodeURIComponent(tagQuery) + '&limit=4')
|
||||||
|
]);
|
||||||
|
|
||||||
if (!users.length && !locations.length && !events.length) {
|
const data = searchRes.ok ? await searchRes.json() : {};
|
||||||
overlay.innerHTML = '<div class="topbar-search-hint">Keine Ergebnisse.</div>';
|
const gruppen = gruppenRes.ok ? await gruppenRes.json() : [];
|
||||||
return;
|
const hashtags = hashtagRes.ok ? await hashtagRes.json() : [];
|
||||||
}
|
|
||||||
|
const { users = [], locations = [], events = [] } = data;
|
||||||
|
const gruppenSlice = gruppen.slice(0, 3);
|
||||||
|
|
||||||
let html = '';
|
let html = '';
|
||||||
|
|
||||||
|
if (!users.length && !locations.length && !events.length && !gruppenSlice.length && !hashtags.length) {
|
||||||
|
html += '<div class="topbar-search-hint">Keine Ergebnisse.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
if (users.length) {
|
if (users.length) {
|
||||||
html += `<div class="topbar-search-section">Personen</div>`;
|
html += `<div class="topbar-search-section">Personen</div>`;
|
||||||
html += users.map(u => {
|
html += users.map(u => {
|
||||||
@@ -213,6 +221,26 @@
|
|||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (gruppenSlice.length) {
|
||||||
|
html += `<div class="topbar-search-section">Gruppen</div>`;
|
||||||
|
html += gruppenSlice.map(g => {
|
||||||
|
const av = g.bild
|
||||||
|
? `<img src="data:image/jpeg;base64,${esc(g.bild)}" class="topbar-search-avatar" alt="">`
|
||||||
|
: `<span class="topbar-search-avatar topbar-search-avatar--placeholder">👥</span>`;
|
||||||
|
return `<a href="/community/gruppe.html?gruppeId=${esc(g.gruppeId)}" class="topbar-search-result">
|
||||||
|
${av}<span>${esc(g.name)}</span></a>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hashtags.length) {
|
||||||
|
html += `<div class="topbar-search-section">Hashtags</div>`;
|
||||||
|
html += `<div style="display:flex;flex-wrap:wrap;gap:0.4rem;padding:0.4rem 0.75rem;">`;
|
||||||
|
html += hashtags.map(tag =>
|
||||||
|
`<a href="/community/feed.html?tag=${encodeURIComponent(tag)}" style="display:inline-block;padding:0.25rem 0.65rem;background:var(--color-secondary);border-radius:14px;font-size:0.82rem;font-weight:600;color:var(--color-primary);text-decoration:none;">#${esc(tag)}</a>`
|
||||||
|
).join('');
|
||||||
|
html += `</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
html += `<a href="/search.html?q=${encodeURIComponent(q)}" class="topbar-search-all">Alle Ergebnisse anzeigen →</a>`;
|
html += `<a href="/search.html?q=${encodeURIComponent(q)}" class="topbar-search-all">Alle Ergebnisse anzeigen →</a>`;
|
||||||
overlay.innerHTML = html;
|
overlay.innerHTML = html;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -163,6 +163,33 @@
|
|||||||
transition: border-color 0.15s, color 0.15s;
|
transition: border-color 0.15s, color 0.15s;
|
||||||
}
|
}
|
||||||
.search-load-more:hover { border-color: var(--color-primary); color: var(--color-primary); background: none; }
|
.search-load-more:hover { border-color: var(--color-primary); color: var(--color-primary); background: none; }
|
||||||
|
|
||||||
|
.hashtag-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.4rem 0.85rem;
|
||||||
|
background: var(--color-card);
|
||||||
|
border: 1px solid var(--color-secondary);
|
||||||
|
border-radius: 20px;
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.hashtag-chip:hover { border-color: var(--color-primary); background: var(--color-secondary); }
|
||||||
|
|
||||||
|
/* Dialog (Gruppen-Beitritt) */
|
||||||
|
.dialog-backdrop { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.6); z-index:200; align-items:center; justify-content:center; }
|
||||||
|
.dialog-backdrop.visible { display:flex; }
|
||||||
|
.dialog { background:var(--color-card); border:1px solid var(--color-secondary); border-radius:12px; padding:1.75rem; width:100%; max-width:420px; box-shadow:0 8px 32px rgba(0,0,0,0.6); max-height:90vh; overflow-y:auto; }
|
||||||
|
.dialog h3 { color:var(--color-primary); font-size:1.1rem; margin-bottom:1.25rem; }
|
||||||
|
.dialog label { display:block; font-size:0.8rem; color:#aaa; margin-bottom:0.3rem; margin-top:1rem; }
|
||||||
|
.dialog textarea { width:100%; padding:0.6rem 0.85rem; border:1px solid var(--color-secondary); border-radius:6px; background:var(--color-secondary); color:var(--color-text); font-size:0.95rem; outline:none; transition:border-color 0.2s; resize:vertical; min-height:80px; box-sizing:border-box; }
|
||||||
|
.dialog textarea:focus { border-color:var(--color-primary); }
|
||||||
|
.dialog-actions { display:flex; justify-content:flex-end; gap:0.75rem; margin-top:1.5rem; }
|
||||||
|
.dialog-actions button { flex:none; margin:0; padding:0.55rem 1.1rem; font-size:0.9rem; width:auto; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="app">
|
<body class="app">
|
||||||
@@ -173,7 +200,7 @@
|
|||||||
<div class="search-hero">
|
<div class="search-hero">
|
||||||
<div class="search-hero-input-wrap">
|
<div class="search-hero-input-wrap">
|
||||||
<span class="search-hero-icon" id="searchIcon"></span>
|
<span class="search-hero-icon" id="searchIcon"></span>
|
||||||
<input type="text" id="searchInput" placeholder="Suchen nach Personen, Locations, Veranstaltungen…"
|
<input type="text" id="searchInput" placeholder="Suchen nach Personen, Locations, Veranstaltungen, Gruppen…"
|
||||||
autocomplete="off" spellcheck="false">
|
autocomplete="off" spellcheck="false">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -188,6 +215,12 @@
|
|||||||
<button class="search-tab-btn" data-tab="events">
|
<button class="search-tab-btn" data-tab="events">
|
||||||
Veranstaltungen <span class="search-tab-count" id="countEvents">0</span>
|
Veranstaltungen <span class="search-tab-count" id="countEvents">0</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="search-tab-btn" data-tab="gruppen">
|
||||||
|
Gruppen <span class="search-tab-count" id="countGruppen">0</span>
|
||||||
|
</button>
|
||||||
|
<button class="search-tab-btn" data-tab="hashtags">
|
||||||
|
Hashtags <span class="search-tab-count" id="countHashtags">0</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="search-panel active" id="panel-users">
|
<div class="search-panel active" id="panel-users">
|
||||||
@@ -207,6 +240,31 @@
|
|||||||
<div class="search-grid" id="gridEvents"></div>
|
<div class="search-grid" id="gridEvents"></div>
|
||||||
<button class="search-load-more" id="moreEvents" style="display:none;">Mehr laden</button>
|
<button class="search-load-more" id="moreEvents" style="display:none;">Mehr laden</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="search-panel" id="panel-gruppen">
|
||||||
|
<div class="search-loading" id="loadingGruppen" style="display:none;">Wird geladen…</div>
|
||||||
|
<div class="search-grid" id="gridGruppen"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="search-panel" id="panel-hashtags">
|
||||||
|
<div class="search-loading" id="loadingHashtags" style="display:none;">Wird geladen…</div>
|
||||||
|
<div id="gridHashtags" style="display:flex; flex-wrap:wrap; gap:0.6rem; padding-top:0.25rem;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Beitrittsanfrage Dialog -->
|
||||||
|
<div class="dialog-backdrop" id="searchJoinDialog">
|
||||||
|
<div class="dialog">
|
||||||
|
<h3>Beitrittsanfrage senden</h3>
|
||||||
|
<p id="searchJoinGroupName" style="font-weight:600; margin-bottom:0.5rem;"></p>
|
||||||
|
<label>Nachricht (optional)</label>
|
||||||
|
<textarea id="searchJoinNachricht" placeholder="Warum möchtest du beitreten?"></textarea>
|
||||||
|
<p class="message error" id="searchJoinError" style="display:none; margin-top:0.75rem;"></p>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button class="secondary" id="searchJoinCancelBtn">Abbrechen</button>
|
||||||
|
<button id="searchJoinSendBtn">Anfrage senden</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -279,10 +337,12 @@
|
|||||||
document.getElementById('grid' + cap(t)).innerHTML = '';
|
document.getElementById('grid' + cap(t)).innerHTML = '';
|
||||||
document.getElementById('more' + cap(t)).style.display = 'none';
|
document.getElementById('more' + cap(t)).style.display = 'none';
|
||||||
});
|
});
|
||||||
// Alle drei Typen parallel laden
|
// Alle Typen parallel laden
|
||||||
loadChunk('users');
|
loadChunk('users');
|
||||||
loadChunk('locations');
|
loadChunk('locations');
|
||||||
loadChunk('events');
|
loadChunk('events');
|
||||||
|
loadGruppen(q);
|
||||||
|
loadHashtags(q);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearAll() {
|
function clearAll() {
|
||||||
@@ -295,6 +355,12 @@
|
|||||||
document.getElementById('count' + cap(t)).textContent = '0';
|
document.getElementById('count' + cap(t)).textContent = '0';
|
||||||
document.getElementById('loading' + cap(t)).style.display = 'none';
|
document.getElementById('loading' + cap(t)).style.display = 'none';
|
||||||
});
|
});
|
||||||
|
document.getElementById('gridGruppen').innerHTML = '';
|
||||||
|
document.getElementById('countGruppen').textContent = '0';
|
||||||
|
document.getElementById('loadingGruppen').style.display = 'none';
|
||||||
|
document.getElementById('gridHashtags').innerHTML = '';
|
||||||
|
document.getElementById('countHashtags').textContent = '0';
|
||||||
|
document.getElementById('loadingHashtags').style.display = 'none';
|
||||||
const url = new URL(window.location);
|
const url = new URL(window.location);
|
||||||
url.searchParams.delete('q');
|
url.searchParams.delete('q');
|
||||||
history.replaceState(null, '', url);
|
history.replaceState(null, '', url);
|
||||||
@@ -374,14 +440,185 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Gruppen-Suche ──
|
||||||
|
|
||||||
|
async function loadGruppen(q) {
|
||||||
|
const loadingEl = document.getElementById('loadingGruppen');
|
||||||
|
const grid = document.getElementById('gridGruppen');
|
||||||
|
loadingEl.style.display = '';
|
||||||
|
grid.innerHTML = '';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/gruppen/search?q=' + encodeURIComponent(q));
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
const data = await res.json();
|
||||||
|
document.getElementById('countGruppen').textContent = data.length;
|
||||||
|
if (!data.length) {
|
||||||
|
grid.innerHTML = '<div class="search-empty" style="grid-column:1/-1;">Keine Ergebnisse.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
data.forEach(g => grid.appendChild(buildGruppeCard(g)));
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
loadingEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGruppeCard(g) {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'search-card';
|
||||||
|
card.style.cssText = 'justify-content:space-between; cursor:pointer;';
|
||||||
|
card.addEventListener('click', () => { location.href = '/community/gruppe.html?gruppeId=' + g.gruppeId; });
|
||||||
|
|
||||||
|
const av = g.bild
|
||||||
|
? `<div class="search-card-avatar search-card-avatar--square"><img src="data:image/jpeg;base64,${esc(g.bild)}" alt=""></div>`
|
||||||
|
: `<div class="search-card-avatar search-card-avatar--square">👥</div>`;
|
||||||
|
const privBadge = g.isPrivate ? ' 🔒' : '';
|
||||||
|
const sub = g.memberCount + ' Mitglied' + (g.memberCount !== 1 ? 'er' : '');
|
||||||
|
|
||||||
|
const info = document.createElement('div');
|
||||||
|
info.style.cssText = 'display:flex; align-items:center; gap:0.75rem; min-width:0; flex:1;';
|
||||||
|
info.innerHTML = `${av}<div class="search-card-body"><div class="search-card-name">${esc(g.name)}${privBadge}</div><div class="search-card-sub">${esc(sub)}</div></div>`;
|
||||||
|
card.appendChild(info);
|
||||||
|
|
||||||
|
if (!g.myRole) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.style.cssText = 'font-size:0.78rem; padding:0.3rem 0.65rem; width:auto; margin:0; white-space:nowrap; flex-shrink:0; margin-left:0.5rem;';
|
||||||
|
if (g.myRequestStatus === 'AUSSTEHEND') {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.style.opacity = '0.6';
|
||||||
|
btn.textContent = 'Anfrage ausstehend';
|
||||||
|
} else if (g.isPrivate) {
|
||||||
|
btn.textContent = 'Anfrage senden';
|
||||||
|
btn.addEventListener('click', e => { e.stopPropagation(); openSearchJoinDialog(g.gruppeId, g.name); });
|
||||||
|
} else {
|
||||||
|
btn.textContent = 'Beitreten';
|
||||||
|
btn.addEventListener('click', e => { e.stopPropagation(); joinGruppeSearch(g.gruppeId, btn); });
|
||||||
|
}
|
||||||
|
card.appendChild(btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hashtag-Suche ──
|
||||||
|
|
||||||
|
async function loadHashtags(q) {
|
||||||
|
const loadingEl = document.getElementById('loadingHashtags');
|
||||||
|
const grid = document.getElementById('gridHashtags');
|
||||||
|
loadingEl.style.display = '';
|
||||||
|
grid.innerHTML = '';
|
||||||
|
try {
|
||||||
|
const raw = q.startsWith('#') ? q.slice(1) : q;
|
||||||
|
const res = await fetch('/hashtags/suggest?q=' + encodeURIComponent(raw) + '&limit=20');
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
const tags = await res.json();
|
||||||
|
document.getElementById('countHashtags').textContent = tags.length;
|
||||||
|
if (!tags.length) {
|
||||||
|
grid.innerHTML = '<div class="search-empty">Keine Hashtags gefunden.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tags.forEach(tag => {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.className = 'hashtag-chip';
|
||||||
|
a.href = '/community/feed.html?tag=' + encodeURIComponent(tag);
|
||||||
|
a.textContent = '#' + tag;
|
||||||
|
grid.appendChild(a);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
loadingEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function joinGruppeSearch(gruppeId, btn) {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '…';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/gruppen/' + gruppeId + '/join', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: '{}'
|
||||||
|
});
|
||||||
|
if (res.ok || res.status === 201) {
|
||||||
|
btn.textContent = 'Beigetreten ✓';
|
||||||
|
} else {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Beitreten';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Beitreten';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Join-Dialog für Gruppen ──
|
||||||
|
|
||||||
|
let searchJoinGruppeId = null;
|
||||||
|
|
||||||
|
function openSearchJoinDialog(gruppeId, name) {
|
||||||
|
searchJoinGruppeId = gruppeId;
|
||||||
|
document.getElementById('searchJoinGroupName').textContent = name;
|
||||||
|
document.getElementById('searchJoinNachricht').value = '';
|
||||||
|
document.getElementById('searchJoinError').style.display = 'none';
|
||||||
|
document.getElementById('searchJoinDialog').classList.add('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSearchJoinDialog() {
|
||||||
|
document.getElementById('searchJoinDialog').classList.remove('visible');
|
||||||
|
searchJoinGruppeId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendSearchJoinRequest() {
|
||||||
|
if (!searchJoinGruppeId) return;
|
||||||
|
document.getElementById('searchJoinError').style.display = 'none';
|
||||||
|
const nachricht = document.getElementById('searchJoinNachricht').value.trim() || null;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/gruppen/' + searchJoinGruppeId + '/join', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ nachricht })
|
||||||
|
});
|
||||||
|
if (res.ok || res.status === 201) {
|
||||||
|
closeSearchJoinDialog();
|
||||||
|
if (currentQuery.length >= 2) loadGruppen(currentQuery);
|
||||||
|
} else {
|
||||||
|
const el = document.getElementById('searchJoinError');
|
||||||
|
el.textContent = 'Fehler beim Senden der Anfrage.';
|
||||||
|
el.style.display = 'block';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const el = document.getElementById('searchJoinError');
|
||||||
|
el.textContent = 'Fehler: ' + e.message;
|
||||||
|
el.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('searchJoinCancelBtn').addEventListener('click', closeSearchJoinDialog);
|
||||||
|
document.getElementById('searchJoinSendBtn').addEventListener('click', sendSearchJoinRequest);
|
||||||
|
document.getElementById('searchJoinDialog').addEventListener('click', e => {
|
||||||
|
if (e.target === document.getElementById('searchJoinDialog')) closeSearchJoinDialog();
|
||||||
|
});
|
||||||
|
|
||||||
function cap(s) { return s.charAt(0).toUpperCase() + s.slice(1); }
|
function cap(s) { return s.charAt(0).toUpperCase() + s.slice(1); }
|
||||||
|
|
||||||
// ── URL-Parameter beim Start auslesen ──
|
// ── URL-Parameter beim Start auslesen ──
|
||||||
const initQ = new URLSearchParams(window.location.search).get('q') || '';
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const initQ = params.get('q') || '';
|
||||||
|
const initTab = params.get('tab') || '';
|
||||||
|
|
||||||
|
if (initTab) {
|
||||||
|
const tabBtn = document.querySelector(`.search-tab-btn[data-tab="${initTab}"]`);
|
||||||
|
if (tabBtn) tabBtn.click();
|
||||||
|
}
|
||||||
|
|
||||||
if (initQ.length >= 2) {
|
if (initQ.length >= 2) {
|
||||||
input.value = initQ;
|
input.value = initQ;
|
||||||
// Warten bis icons.js geladen ist
|
|
||||||
setTimeout(() => startSearch(initQ), 100);
|
setTimeout(() => startSearch(initQ), 100);
|
||||||
|
} else if (initTab === 'hashtags') {
|
||||||
|
// Populäre Tags zeigen wenn kein Suchbegriff
|
||||||
|
setTimeout(() => loadHashtags(''), 100);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -243,6 +243,33 @@
|
|||||||
.lb-comment-compose button { width:auto; margin:0; padding:0.35rem 0.75rem; font-size:0.8rem; }
|
.lb-comment-compose button { width:auto; margin:0; padding:0.35rem 0.75rem; font-size:0.8rem; }
|
||||||
@media (max-width:650px) { .lb-layout { flex-direction:column; height:95vh; } .lb-post-side { border-right:none; border-bottom:1px solid var(--color-secondary); max-height:55vh; } .lb-comments-panel { width:100%; } }
|
@media (max-width:650px) { .lb-layout { flex-direction:column; height:95vh; } .lb-post-side { border-right:none; border-bottom:1px solid var(--color-secondary); max-height:55vh; } .lb-comments-panel { width:100%; } }
|
||||||
|
|
||||||
|
/* ── Spiel starten ── */
|
||||||
|
.start-game-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.start-game-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
background: var(--color-card);
|
||||||
|
border: 1px solid var(--color-secondary);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.25rem 1rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--color-text);
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.start-game-card:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
background: rgba(var(--color-primary-rgb,233,69,96),0.06);
|
||||||
|
}
|
||||||
|
.start-game-icon { font-size: 2rem; line-height: 1; }
|
||||||
|
.start-game-title { font-size: 0.9rem; font-weight: 600; }
|
||||||
|
|
||||||
/* ── Neue Mitglieder ── */
|
/* ── Neue Mitglieder ── */
|
||||||
.new-members-strip {
|
.new-members-strip {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -306,6 +333,25 @@
|
|||||||
<div class="active-game-list" id="activeLockList"></div>
|
<div class="active-game-list" id="activeLockList"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Kein Spiel aktiv – Starten -->
|
||||||
|
<div id="startGameSection" style="display:none;">
|
||||||
|
<div class="section-label">Spiel starten 🎮</div>
|
||||||
|
<div class="start-game-grid">
|
||||||
|
<a href="/games/vanilla/neuvanilla.html" class="start-game-card">
|
||||||
|
<div class="start-game-icon">🎭</div>
|
||||||
|
<div class="start-game-title">Vanilla Game starten</div>
|
||||||
|
</a>
|
||||||
|
<a href="/games/bdsm/neubdsm.html" class="start-game-card">
|
||||||
|
<div class="start-game-icon">⛓</div>
|
||||||
|
<div class="start-game-title">BDSM-Game starten</div>
|
||||||
|
</a>
|
||||||
|
<a href="/games/chastity/neulock.html" class="start-game-card">
|
||||||
|
<div class="start-game-icon">🔒</div>
|
||||||
|
<div class="start-game-title">Chastity-Lock starten</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Einladungen -->
|
<!-- Einladungen -->
|
||||||
<div id="invitesSection" style="display:none;">
|
<div id="invitesSection" style="display:none;">
|
||||||
<div class="section-label">Einladungen 📨</div>
|
<div class="section-label">Einladungen 📨</div>
|
||||||
@@ -423,6 +469,7 @@
|
|||||||
|
|
||||||
<script src="/js/shared.js"></script>
|
<script src="/js/shared.js"></script>
|
||||||
<script src="/js/icons.js"></script>
|
<script src="/js/icons.js"></script>
|
||||||
|
<script src="/js/hashtag.js"></script>
|
||||||
<script src="/js/nav.js"></script>
|
<script src="/js/nav.js"></script>
|
||||||
<script>
|
<script>
|
||||||
let myUserId = null;
|
let myUserId = null;
|
||||||
@@ -436,14 +483,20 @@
|
|||||||
if (user) {
|
if (user) {
|
||||||
myUserId = user.userId;
|
myUserId = user.userId;
|
||||||
document.getElementById('greeting').textContent = 'Willkommen zurück, ' + user.name + '!';
|
document.getElementById('greeting').textContent = 'Willkommen zurück, ' + user.name + '!';
|
||||||
loadActiveGames(user.userId);
|
Promise.all([loadActiveGames(user.userId), loadActiveLock()]).then(() => {
|
||||||
loadActiveLock();
|
const hasGames = document.getElementById('activeGamesSection').style.display !== 'none';
|
||||||
|
const hasLock = document.getElementById('activeLockSection').style.display !== 'none';
|
||||||
|
if (!hasGames && !hasLock) {
|
||||||
|
document.getElementById('startGameSection').style.display = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
loadInvites();
|
loadInvites();
|
||||||
loadFriendRequests();
|
loadFriendRequests();
|
||||||
loadVisitors();
|
loadVisitors();
|
||||||
loadMyEvents();
|
loadMyEvents();
|
||||||
loadLocEvents();
|
loadLocEvents();
|
||||||
loadFeed();
|
loadFeed();
|
||||||
|
attachHashtagAutocomplete(document.getElementById('homeComposeText'));
|
||||||
if (user.datingAktiv) {
|
if (user.datingAktiv) {
|
||||||
loadWhoLikesMe();
|
loadWhoLikesMe();
|
||||||
loadMatches();
|
loadMatches();
|
||||||
@@ -931,7 +984,7 @@
|
|||||||
<div class="post-meta">${fmtDate(p.createdAt)}${groupBadge}</div>
|
<div class="post-meta">${fmtDate(p.createdAt)}${groupBadge}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="post-text">${esc(p.text || '')}</div>
|
<div class="post-text">${renderTextWithHashtags(p.text || '')}</div>
|
||||||
${bildHtml}
|
${bildHtml}
|
||||||
${umfrageHtml}
|
${umfrageHtml}
|
||||||
<div class="post-actions">
|
<div class="post-actions">
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ services:
|
|||||||
MYSQL_USER: ${DB_USER}
|
MYSQL_USER: ${DB_USER}
|
||||||
MYSQL_PASSWORD: ${DB_PASSWORD}
|
MYSQL_PASSWORD: ${DB_PASSWORD}
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:3306:3306"
|
- "3306:3306"
|
||||||
volumes:
|
volumes:
|
||||||
# Format: [Pfad auf dem Proxmox-Host]:[Pfad im Container]
|
# Format: [Pfad auf dem Proxmox-Host]:[Pfad im Container]
|
||||||
- /mnt/pve_nas/.mysql_data:/var/lib/mysql
|
- /mnt/pve_nas/.mysql_data:/var/lib/mysql
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ public class SecurityConfig {
|
|||||||
.requestMatchers("/sessionbdsmingame.html").authenticated()
|
.requestMatchers("/sessionbdsmingame.html").authenticated()
|
||||||
.requestMatchers("/games/bdsm/neubdsm.html").authenticated()
|
.requestMatchers("/games/bdsm/neubdsm.html").authenticated()
|
||||||
.requestMatchers("/games/bdsm/bdsmingame.html").authenticated()
|
.requestMatchers("/games/bdsm/bdsmingame.html").authenticated()
|
||||||
.requestMatchers("/community/personen-suchen.html").authenticated()
|
|
||||||
.requestMatchers("/community/freunde.html").authenticated()
|
.requestMatchers("/community/freunde.html").authenticated()
|
||||||
.requestMatchers("/community/nachrichten.html").authenticated()
|
.requestMatchers("/community/nachrichten.html").authenticated()
|
||||||
.requestMatchers("/community/benutzer.html").authenticated()
|
.requestMatchers("/community/benutzer.html").authenticated()
|
||||||
@@ -82,6 +81,7 @@ public class SecurityConfig {
|
|||||||
.requestMatchers("/community/event-detail.html").authenticated()
|
.requestMatchers("/community/event-detail.html").authenticated()
|
||||||
.requestMatchers("/gruppen/**").authenticated()
|
.requestMatchers("/gruppen/**").authenticated()
|
||||||
.requestMatchers("/feed/**").authenticated()
|
.requestMatchers("/feed/**").authenticated()
|
||||||
|
.requestMatchers("/hashtags/**").authenticated()
|
||||||
.requestMatchers("/notifications/**").authenticated()
|
.requestMatchers("/notifications/**").authenticated()
|
||||||
.requestMatchers("/events/**").authenticated()
|
.requestMatchers("/events/**").authenticated()
|
||||||
.requestMatchers("/*.html").permitAll()
|
.requestMatchers("/*.html").permitAll()
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import java.time.LocalDateTime;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
@@ -25,6 +27,8 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
import de.oaa.xxx.feed.dto.FeedItemDto;
|
import de.oaa.xxx.feed.dto.FeedItemDto;
|
||||||
import de.oaa.xxx.feed.dto.FeedPostRequest;
|
import de.oaa.xxx.feed.dto.FeedPostRequest;
|
||||||
import de.oaa.xxx.feed.entity.FeedPostEntity;
|
import de.oaa.xxx.feed.entity.FeedPostEntity;
|
||||||
|
import de.oaa.xxx.hashtag.HashtagService;
|
||||||
|
import de.oaa.xxx.hashtag.PostHashtagEntity;
|
||||||
import de.oaa.xxx.feed.entity.FeedPostOptionEntity;
|
import de.oaa.xxx.feed.entity.FeedPostOptionEntity;
|
||||||
import de.oaa.xxx.feed.entity.FeedPostVoteEntity;
|
import de.oaa.xxx.feed.entity.FeedPostVoteEntity;
|
||||||
import de.oaa.xxx.feed.repository.FeedPostLikeRepository;
|
import de.oaa.xxx.feed.repository.FeedPostLikeRepository;
|
||||||
@@ -33,6 +37,7 @@ import de.oaa.xxx.feed.repository.FeedPostRepository;
|
|||||||
import de.oaa.xxx.feed.repository.FeedPostVoteRepository;
|
import de.oaa.xxx.feed.repository.FeedPostVoteRepository;
|
||||||
import de.oaa.xxx.gruppe.BeitragTyp;
|
import de.oaa.xxx.gruppe.BeitragTyp;
|
||||||
import de.oaa.xxx.gruppe.dto.UmfrageOptionDto;
|
import de.oaa.xxx.gruppe.dto.UmfrageOptionDto;
|
||||||
|
import de.oaa.xxx.gruppe.entity.GruppeEntity;
|
||||||
import de.oaa.xxx.gruppe.entity.GruppenbeitragEntity;
|
import de.oaa.xxx.gruppe.entity.GruppenbeitragEntity;
|
||||||
import de.oaa.xxx.gruppe.entity.UmfrageStimmeEntity;
|
import de.oaa.xxx.gruppe.entity.UmfrageStimmeEntity;
|
||||||
import de.oaa.xxx.gruppe.repository.GruppeRepository;
|
import de.oaa.xxx.gruppe.repository.GruppeRepository;
|
||||||
@@ -70,6 +75,7 @@ public class FeedController {
|
|||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
private final LikeService likeService;
|
private final LikeService likeService;
|
||||||
|
private final HashtagService hashtagService;
|
||||||
|
|
||||||
public FeedController(FeedPostRepository feedPostRepository,
|
public FeedController(FeedPostRepository feedPostRepository,
|
||||||
FeedPostLikeRepository feedPostLikeRepository,
|
FeedPostLikeRepository feedPostLikeRepository,
|
||||||
@@ -85,7 +91,8 @@ public class FeedController {
|
|||||||
KommentarRepository kommentarRepository,
|
KommentarRepository kommentarRepository,
|
||||||
UserRepository userRepository,
|
UserRepository userRepository,
|
||||||
UserService userService,
|
UserService userService,
|
||||||
LikeService likeService) {
|
LikeService likeService,
|
||||||
|
HashtagService hashtagService) {
|
||||||
this.feedPostRepository = feedPostRepository;
|
this.feedPostRepository = feedPostRepository;
|
||||||
this.feedPostLikeRepository = feedPostLikeRepository;
|
this.feedPostLikeRepository = feedPostLikeRepository;
|
||||||
this.feedPostOptionRepository = feedPostOptionRepository;
|
this.feedPostOptionRepository = feedPostOptionRepository;
|
||||||
@@ -101,6 +108,7 @@ public class FeedController {
|
|||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.userService = userService;
|
this.userService = userService;
|
||||||
this.likeService = likeService;
|
this.likeService = likeService;
|
||||||
|
this.hashtagService = hashtagService;
|
||||||
}
|
}
|
||||||
|
|
||||||
record FeedPage(List<FeedItemDto> posts, boolean hasMore) {}
|
record FeedPage(List<FeedItemDto> posts, boolean hasMore) {}
|
||||||
@@ -131,6 +139,7 @@ public class FeedController {
|
|||||||
post.setPublic(req.isPublic());
|
post.setPublic(req.isPublic());
|
||||||
post.setCreatedAt(LocalDateTime.now());
|
post.setCreatedAt(LocalDateTime.now());
|
||||||
feedPostRepository.save(post);
|
feedPostRepository.save(post);
|
||||||
|
hashtagService.saveForPost(post.getText(), "FEED", post.getPostId(), post.getCreatedAt());
|
||||||
LOGGER.info("User {} hat Feed-Post {} erstellt (Typ: {}, public: {})", myId, post.getPostId(), typ, post.isPublic());
|
LOGGER.info("User {} hat Feed-Post {} erstellt (Typ: {}, public: {})", myId, post.getPostId(), typ, post.isPublic());
|
||||||
|
|
||||||
if (typ == BeitragTyp.UMFRAGE && req.optionen() != null) {
|
if (typ == BeitragTyp.UMFRAGE && req.optionen() != null) {
|
||||||
@@ -251,6 +260,55 @@ public class FeedController {
|
|||||||
return ResponseEntity.ok(new FeedPage(items, !nextPage.isEmpty()));
|
return ResponseEntity.ok(new FeedPage(items, !nextPage.isEmpty()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── GET /feed/hashtag?tag= ──
|
||||||
|
|
||||||
|
@GetMapping("/hashtag")
|
||||||
|
public ResponseEntity<FeedPage> getHashtagFeed(@RequestParam String tag,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "10") int size,
|
||||||
|
Principal principal) {
|
||||||
|
UUID myId = resolveMyId(principal);
|
||||||
|
if (myId == null) return ResponseEntity.status(401).build();
|
||||||
|
|
||||||
|
List<PostHashtagEntity> refs = hashtagService.getPostRefs(tag);
|
||||||
|
if (refs.isEmpty()) return ResponseEntity.ok(new FeedPage(List.of(), false));
|
||||||
|
|
||||||
|
Set<UUID> myGroupIds = mitgliedRepository.findByUserId(myId).stream()
|
||||||
|
.map(m -> m.getGruppeId())
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
|
||||||
|
Set<UUID> friendIds = friendshipRepository
|
||||||
|
.findFriends(myId, FriendshipEntity.Status.ACCEPTED)
|
||||||
|
.stream()
|
||||||
|
.map(f -> f.getSenderId().equals(myId) ? f.getReceiverId() : f.getSenderId())
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
|
||||||
|
List<FeedItemDto> all = new ArrayList<>();
|
||||||
|
for (PostHashtagEntity ref : refs) {
|
||||||
|
if ("FEED".equals(ref.getPostType())) {
|
||||||
|
feedPostRepository.findById(ref.getPostId()).ifPresent(p -> {
|
||||||
|
boolean visible = p.isPublic()
|
||||||
|
|| p.getAuthorId().equals(myId)
|
||||||
|
|| friendIds.contains(p.getAuthorId());
|
||||||
|
if (visible) all.add(toFeedItemDtoFromPost(p, myId));
|
||||||
|
});
|
||||||
|
} else if ("GROUP".equals(ref.getPostType())) {
|
||||||
|
gruppenbeitragRepository.findById(ref.getPostId()).ifPresent(b -> {
|
||||||
|
GruppeEntity gruppe = gruppeRepository.findById(b.getGruppeId()).orElse(null);
|
||||||
|
if (gruppe != null && (!gruppe.isPrivate() || myGroupIds.contains(gruppe.getGruppeId()))) {
|
||||||
|
all.add(toFeedItemDtoFromGruppe(b, myId));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
all.sort(Comparator.comparing(FeedItemDto::createdAt).reversed());
|
||||||
|
int from = page * size;
|
||||||
|
int to = Math.min(from + size, all.size());
|
||||||
|
List<FeedItemDto> items = from < all.size() ? all.subList(from, to) : List.of();
|
||||||
|
return ResponseEntity.ok(new FeedPage(items, to < all.size()));
|
||||||
|
}
|
||||||
|
|
||||||
// ── POST /feed/posts/{id}/like ──
|
// ── POST /feed/posts/{id}/like ──
|
||||||
|
|
||||||
@PostMapping("/posts/{id}/like")
|
@PostMapping("/posts/{id}/like")
|
||||||
@@ -316,6 +374,7 @@ public class FeedController {
|
|||||||
|
|
||||||
if (!post.getAuthorId().equals(myId)) return ResponseEntity.status(403).build();
|
if (!post.getAuthorId().equals(myId)) return ResponseEntity.status(403).build();
|
||||||
|
|
||||||
|
hashtagService.deleteForPost("FEED", id);
|
||||||
feedPostVoteRepository.deleteByPostId(id);
|
feedPostVoteRepository.deleteByPostId(id);
|
||||||
feedPostOptionRepository.deleteByPostId(id);
|
feedPostOptionRepository.deleteByPostId(id);
|
||||||
feedPostLikeRepository.deleteByPostId(id);
|
feedPostLikeRepository.deleteByPostId(id);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package de.oaa.xxx.gruppe;
|
|||||||
import de.oaa.xxx.gruppe.dto.*;
|
import de.oaa.xxx.gruppe.dto.*;
|
||||||
import de.oaa.xxx.gruppe.entity.*;
|
import de.oaa.xxx.gruppe.entity.*;
|
||||||
import de.oaa.xxx.gruppe.repository.*;
|
import de.oaa.xxx.gruppe.repository.*;
|
||||||
|
import de.oaa.xxx.hashtag.HashtagService;
|
||||||
import de.oaa.xxx.social.LikeService;
|
import de.oaa.xxx.social.LikeService;
|
||||||
import de.oaa.xxx.social.repository.KommentarRepository;
|
import de.oaa.xxx.social.repository.KommentarRepository;
|
||||||
import de.oaa.xxx.user.UserEntity;
|
import de.oaa.xxx.user.UserEntity;
|
||||||
@@ -35,6 +36,7 @@ public class GruppenbeitragController {
|
|||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
private final LikeService likeService;
|
private final LikeService likeService;
|
||||||
|
private final HashtagService hashtagService;
|
||||||
|
|
||||||
public GruppenbeitragController(GruppeRepository gruppeRepository,
|
public GruppenbeitragController(GruppeRepository gruppeRepository,
|
||||||
GruppenmitgliedRepository mitgliedRepository,
|
GruppenmitgliedRepository mitgliedRepository,
|
||||||
@@ -46,7 +48,8 @@ public class GruppenbeitragController {
|
|||||||
KommentarRepository kommentarRepository,
|
KommentarRepository kommentarRepository,
|
||||||
UserRepository userRepository,
|
UserRepository userRepository,
|
||||||
UserService userService,
|
UserService userService,
|
||||||
LikeService likeService) {
|
LikeService likeService,
|
||||||
|
HashtagService hashtagService) {
|
||||||
this.gruppeRepository = gruppeRepository;
|
this.gruppeRepository = gruppeRepository;
|
||||||
this.mitgliedRepository = mitgliedRepository;
|
this.mitgliedRepository = mitgliedRepository;
|
||||||
this.beitragRepository = beitragRepository;
|
this.beitragRepository = beitragRepository;
|
||||||
@@ -58,6 +61,7 @@ public class GruppenbeitragController {
|
|||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.userService = userService;
|
this.userService = userService;
|
||||||
this.likeService = likeService;
|
this.likeService = likeService;
|
||||||
|
this.hashtagService = hashtagService;
|
||||||
}
|
}
|
||||||
|
|
||||||
record CreateBeitragRequest(String beitragTyp, String text, Boolean multiChoice, List<String> optionen, List<String> bilder) {}
|
record CreateBeitragRequest(String beitragTyp, String text, Boolean multiChoice, List<String> optionen, List<String> bilder) {}
|
||||||
@@ -130,6 +134,7 @@ public class GruppenbeitragController {
|
|||||||
beitrag.setBilder(req.bilder() != null ? req.bilder() : List.of());
|
beitrag.setBilder(req.bilder() != null ? req.bilder() : List.of());
|
||||||
beitrag.setCreatedAt(LocalDateTime.now());
|
beitrag.setCreatedAt(LocalDateTime.now());
|
||||||
beitragRepository.save(beitrag);
|
beitragRepository.save(beitrag);
|
||||||
|
hashtagService.saveForPost(beitrag.getText(), "GROUP", beitrag.getBeitragId(), beitrag.getCreatedAt());
|
||||||
LOGGER.debug("User {} hat Beitrag {} (Typ: {}) in Gruppe {} erstellt", myId, beitrag.getBeitragId(), typ, id);
|
LOGGER.debug("User {} hat Beitrag {} (Typ: {}) in Gruppe {} erstellt", myId, beitrag.getBeitragId(), typ, id);
|
||||||
|
|
||||||
if (typ == BeitragTyp.UMFRAGE && req.optionen() != null) {
|
if (typ == BeitragTyp.UMFRAGE && req.optionen() != null) {
|
||||||
@@ -311,6 +316,7 @@ public class GruppenbeitragController {
|
|||||||
|
|
||||||
private void deleteBeitragCascade(GruppenbeitragEntity beitrag) {
|
private void deleteBeitragCascade(GruppenbeitragEntity beitrag) {
|
||||||
UUID bid = beitrag.getBeitragId();
|
UUID bid = beitrag.getBeitragId();
|
||||||
|
hashtagService.deleteForPost("GROUP", bid);
|
||||||
meldungRepository.deleteByBeitragId(bid);
|
meldungRepository.deleteByBeitragId(bid);
|
||||||
stimmeRepository.deleteByBeitragId(bid);
|
stimmeRepository.deleteByBeitragId(bid);
|
||||||
optionRepository.deleteByBeitragId(bid);
|
optionRepository.deleteByBeitragId(bid);
|
||||||
|
|||||||
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; }
|
.empty-hint { color:var(--color-muted); font-size:0.9rem; margin-top:0.5rem; }
|
||||||
.sentinel { height:1px; }
|
.sentinel { height:1px; }
|
||||||
|
|
||||||
|
.hashtag-banner { display:flex; align-items:center; gap:0.75rem; margin-bottom:1.25rem; padding:0.65rem 1rem; background:var(--color-card); border:1px solid var(--color-secondary); border-radius:8px; }
|
||||||
|
.hashtag-banner-tag { font-size:1.05rem; font-weight:700; color:var(--color-primary); }
|
||||||
|
.hashtag-banner-back { margin-left:auto; font-size:0.82rem; color:var(--color-muted); text-decoration:none; }
|
||||||
|
.hashtag-banner-back:hover { color:var(--color-primary); }
|
||||||
|
|
||||||
/* Lightbox */
|
/* Lightbox */
|
||||||
.lightbox { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.88); z-index:300; align-items:center; justify-content:center; }
|
.lightbox { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.88); z-index:300; align-items:center; justify-content:center; }
|
||||||
.lightbox.open { display:flex; }
|
.lightbox.open { display:flex; }
|
||||||
@@ -92,7 +97,13 @@
|
|||||||
<div class="main">
|
<div class="main">
|
||||||
<div class="content">
|
<div class="content">
|
||||||
|
|
||||||
<div class="tabs">
|
<!-- Hashtag-Banner (nur sichtbar wenn ?tag=… gesetzt) -->
|
||||||
|
<div class="hashtag-banner" id="hashtagBanner" style="display:none;">
|
||||||
|
<span>Posts mit </span><span class="hashtag-banner-tag" id="hashtagBannerLabel"></span>
|
||||||
|
<a class="hashtag-banner-back" href="/community/feed.html">× Zurück zum Feed</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tabs" id="feedTabs">
|
||||||
<button class="tab-btn active" id="tabMine" data-tab="mine" onclick="switchTab('mine', this)">Mein Feed</button>
|
<button class="tab-btn active" id="tabMine" data-tab="mine" onclick="switchTab('mine', this)">Mein Feed</button>
|
||||||
<button class="tab-btn" id="tabPublic" data-tab="public" onclick="switchTab('public', this)">Öffentlicher Feed</button>
|
<button class="tab-btn" id="tabPublic" data-tab="public" onclick="switchTab('public', this)">Öffentlicher Feed</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -140,6 +151,13 @@
|
|||||||
<div class="sentinel" id="publicSentinel"></div>
|
<div class="sentinel" id="publicSentinel"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Hashtag-Feed (wird angezeigt wenn ?tag=… gesetzt) -->
|
||||||
|
<div id="tab-hashtag" style="display:none;">
|
||||||
|
<div id="hashtagFeed"></div>
|
||||||
|
<p class="empty-hint" id="hashtagEmpty" style="display:none;">Keine Posts mit diesem Hashtag.</p>
|
||||||
|
<div class="sentinel" id="hashtagSentinel"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -165,37 +183,68 @@
|
|||||||
|
|
||||||
<script src="/js/shared.js"></script>
|
<script src="/js/shared.js"></script>
|
||||||
<script src="/js/icons.js"></script>
|
<script src="/js/icons.js"></script>
|
||||||
<script src="/js/nav.js"></script>
|
<script src="/js/nav.js"></script>
|
||||||
<script src="/js/social-sidebar.js"></script>
|
<script src="/js/social-sidebar.js"></script>
|
||||||
<script src="/js/meldung.js"></script>
|
<script src="/js/meldung.js"></script>
|
||||||
|
<script src="/js/hashtag.js"></script>
|
||||||
<script>
|
<script>
|
||||||
// ── State ──
|
// ── State ──
|
||||||
let myUserId = null;
|
let myUserId = null;
|
||||||
let activeLbPostId = null;
|
let activeLbPostId = null;
|
||||||
let activeLbPostType = null;
|
let activeLbPostType = null;
|
||||||
|
let activeHashtag = null; // set when ?tag=... is in URL
|
||||||
|
|
||||||
const feedState = {
|
const feedState = {
|
||||||
mine: { page:0, hasMore:true, loading:false, loaded:false },
|
mine: { page:0, hasMore:true, loading:false, loaded:false },
|
||||||
public: { page:0, hasMore:true, loading:false, loaded:false }
|
public: { page:0, hasMore:true, loading:false, loaded:false },
|
||||||
|
hashtag: { page:0, hasMore:true, loading:false, loaded:false }
|
||||||
};
|
};
|
||||||
|
|
||||||
let composeBilderArr = [];
|
let composeBilderArr = [];
|
||||||
|
|
||||||
|
// ── Hashtag-Modus prüfen ──
|
||||||
|
const _urlTag = new URLSearchParams(window.location.search).get('tag');
|
||||||
|
if (_urlTag) {
|
||||||
|
activeHashtag = _urlTag.replace(/^#/, '').toLowerCase();
|
||||||
|
document.getElementById('hashtagBanner').style.display = '';
|
||||||
|
document.getElementById('hashtagBannerLabel').textContent = '#' + activeHashtag;
|
||||||
|
document.getElementById('feedTabs').style.display = 'none';
|
||||||
|
document.getElementById('tab-mine').style.display = 'none';
|
||||||
|
document.getElementById('tab-public').style.display = 'none';
|
||||||
|
document.getElementById('tab-hashtag').style.display = '';
|
||||||
|
document.getElementById('compose').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
// ── Boot ──
|
// ── Boot ──
|
||||||
fetch('/login/me').then(r => r.ok ? r.json() : null).then(async user => {
|
fetch('/login/me').then(r => r.ok ? r.json() : null).then(async user => {
|
||||||
if (user) {
|
if (user) {
|
||||||
myUserId = user.userId;
|
myUserId = user.userId;
|
||||||
const raw = sessionStorage.getItem('feedOpenPost');
|
if (activeHashtag) {
|
||||||
if (raw) {
|
await loadFeed('hashtag');
|
||||||
sessionStorage.removeItem('feedOpenPost');
|
|
||||||
loadFeed('mine');
|
|
||||||
openLbWithData(JSON.parse(raw));
|
|
||||||
} else {
|
} else {
|
||||||
await loadFeed('mine');
|
const raw = sessionStorage.getItem('feedOpenPost');
|
||||||
|
if (raw) {
|
||||||
|
sessionStorage.removeItem('feedOpenPost');
|
||||||
|
loadFeed('mine');
|
||||||
|
openLbWithData(JSON.parse(raw));
|
||||||
|
} else {
|
||||||
|
await loadFeed('mine');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
|
||||||
|
// ── Autocomplete für Compose ──
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const ta = document.getElementById('composeText');
|
||||||
|
if (ta) attachHashtagAutocomplete(ta);
|
||||||
|
});
|
||||||
|
// Fallback falls DOMContentLoaded bereits gefeuert
|
||||||
|
if (document.readyState !== 'loading') {
|
||||||
|
const ta = document.getElementById('composeText');
|
||||||
|
if (ta) attachHashtagAutocomplete(ta);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Tab switching ──
|
// ── Tab switching ──
|
||||||
function switchTab(name, btn) {
|
function switchTab(name, btn) {
|
||||||
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||||||
@@ -218,8 +267,14 @@
|
|||||||
state.loading = true;
|
state.loading = true;
|
||||||
state.loaded = true;
|
state.loaded = true;
|
||||||
try {
|
try {
|
||||||
const endpoint = tab === 'mine' ? '/feed/mine' : '/feed/public';
|
let url;
|
||||||
const res = await fetch(`${endpoint}?page=${state.page}&size=10`);
|
if (tab === 'hashtag') {
|
||||||
|
url = `/feed/hashtag?tag=${encodeURIComponent(activeHashtag)}&page=${state.page}&size=10`;
|
||||||
|
} else {
|
||||||
|
const base = tab === 'mine' ? '/feed/mine' : '/feed/public';
|
||||||
|
url = `${base}?page=${state.page}&size=10`;
|
||||||
|
}
|
||||||
|
const res = await fetch(url);
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const feedEl = document.getElementById(tab + 'Feed');
|
const feedEl = document.getElementById(tab + 'Feed');
|
||||||
@@ -238,12 +293,14 @@
|
|||||||
const observer = new IntersectionObserver(entries => {
|
const observer = new IntersectionObserver(entries => {
|
||||||
entries.forEach(e => {
|
entries.forEach(e => {
|
||||||
if (!e.isIntersecting) return;
|
if (!e.isIntersecting) return;
|
||||||
if (e.target.id === 'mineSentinel') loadFeed('mine');
|
if (e.target.id === 'mineSentinel') loadFeed('mine');
|
||||||
if (e.target.id === 'publicSentinel') loadFeed('public');
|
if (e.target.id === 'publicSentinel') loadFeed('public');
|
||||||
|
if (e.target.id === 'hashtagSentinel') loadFeed('hashtag');
|
||||||
});
|
});
|
||||||
}, { threshold: 0.5 });
|
}, { threshold: 0.5 });
|
||||||
observer.observe(document.getElementById('mineSentinel'));
|
observer.observe(document.getElementById('mineSentinel'));
|
||||||
observer.observe(document.getElementById('publicSentinel'));
|
observer.observe(document.getElementById('publicSentinel'));
|
||||||
|
observer.observe(document.getElementById('hashtagSentinel'));
|
||||||
|
|
||||||
// bilderCarousel und carNav kommen aus shared.js
|
// bilderCarousel und carNav kommen aus shared.js
|
||||||
|
|
||||||
@@ -297,7 +354,7 @@
|
|||||||
</div>
|
</div>
|
||||||
${deleteBtn}
|
${deleteBtn}
|
||||||
</div>
|
</div>
|
||||||
<div class="post-text">${esc(p.text)}</div>
|
<div class="post-text">${renderTextWithHashtags(p.text)}</div>
|
||||||
${bildHtml}
|
${bildHtml}
|
||||||
${umfrageHtml}
|
${umfrageHtml}
|
||||||
<div class="post-actions">
|
<div class="post-actions">
|
||||||
|
|||||||
@@ -143,7 +143,7 @@
|
|||||||
<!-- Friends tab -->
|
<!-- Friends tab -->
|
||||||
<div class="tab-panel active" id="tab-friends">
|
<div class="tab-panel active" id="tab-friends">
|
||||||
<ul class="user-list" id="friendsList"></ul>
|
<ul class="user-list" id="friendsList"></ul>
|
||||||
<p class="empty-hint" id="friendsEmpty" style="display:none;">Du hast noch keine Freunde. <a href="/community/personen-suchen.html" style="color:var(--color-primary);">Personen suchen</a></p>
|
<p class="empty-hint" id="friendsEmpty" style="display:none;">Du hast noch keine Freunde. <a href="/search.html" style="color:var(--color-primary);">Personen suchen</a></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pending tab -->
|
<!-- Pending tab -->
|
||||||
|
|||||||
@@ -297,8 +297,9 @@
|
|||||||
|
|
||||||
<script src="/js/shared.js"></script>
|
<script src="/js/shared.js"></script>
|
||||||
<script src="/js/icons.js"></script>
|
<script src="/js/icons.js"></script>
|
||||||
<script src="/js/nav.js"></script>
|
<script src="/js/nav.js"></script>
|
||||||
<script src="/js/social-sidebar.js"></script>
|
<script src="/js/social-sidebar.js"></script>
|
||||||
|
<script src="/js/hashtag.js"></script>
|
||||||
<script>
|
<script>
|
||||||
// ── Generic modal helpers ──
|
// ── Generic modal helpers ──
|
||||||
|
|
||||||
@@ -377,6 +378,9 @@
|
|||||||
await loadGruppe();
|
await loadGruppe();
|
||||||
await loadPosts();
|
await loadPosts();
|
||||||
|
|
||||||
|
const _composeText = document.getElementById('composeText');
|
||||||
|
if (_composeText) attachHashtagAutocomplete(_composeText);
|
||||||
|
|
||||||
const _savedTab = localStorage.getItem('tab_gruppe_' + gruppeId);
|
const _savedTab = localStorage.getItem('tab_gruppe_' + gruppeId);
|
||||||
if (_savedTab) {
|
if (_savedTab) {
|
||||||
const _btn = document.querySelector(`.tab-btn[data-tab="${_savedTab}"]`);
|
const _btn = document.querySelector(`.tab-btn[data-tab="${_savedTab}"]`);
|
||||||
@@ -488,7 +492,7 @@
|
|||||||
}).join('');
|
}).join('');
|
||||||
body = bars + `<div class="umfrage-total">${total} Stimme${total !== 1 ? 'n' : ''} gesamt${p.multiChoice?' · Multi-Choice':''}</div>`;
|
body = bars + `<div class="umfrage-total">${total} Stimme${total !== 1 ? 'n' : ''} gesamt${p.multiChoice?' · Multi-Choice':''}</div>`;
|
||||||
} else {
|
} else {
|
||||||
body = `<div class="post-text">${esc(p.text)}</div>${bildHtml}`;
|
body = `<div class="post-text">${renderTextWithHashtags(p.text)}</div>${bildHtml}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `
|
return `
|
||||||
@@ -500,7 +504,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="post-date">${fmtDate(p.createdAt)}</div>
|
<div class="post-date">${fmtDate(p.createdAt)}</div>
|
||||||
</div>
|
</div>
|
||||||
${p.beitragTyp === 'UMFRAGE' ? `<div style="font-weight:600;margin-bottom:0.5rem;">${esc(p.text)}</div>${bildHtml}` : ''}
|
${p.beitragTyp === 'UMFRAGE' ? `<div style="font-weight:600;margin-bottom:0.5rem;">${renderTextWithHashtags(p.text)}</div>${bildHtml}` : ''}
|
||||||
${body}
|
${body}
|
||||||
<div class="post-actions">
|
<div class="post-actions">
|
||||||
<button class="post-action-btn ${p.likedByMe?'active':''}" onclick="event.stopPropagation(); toggleLike('${p.beitragId}',this)" id="like-btn-${p.beitragId}">
|
<button class="post-action-btn ${p.likedByMe?'active':''}" onclick="event.stopPropagation(); toggleLike('${p.beitragId}',this)" id="like-btn-${p.beitragId}">
|
||||||
|
|||||||
@@ -22,16 +22,9 @@
|
|||||||
.gruppe-card-body { padding:0.85rem; flex:1; display:flex; flex-direction:column; gap:0.4rem; }
|
.gruppe-card-body { padding:0.85rem; flex:1; display:flex; flex-direction:column; gap:0.4rem; }
|
||||||
.gruppe-card-name { font-weight:700; font-size:1rem; }
|
.gruppe-card-name { font-weight:700; font-size:1rem; }
|
||||||
.gruppe-card-meta { font-size:0.78rem; color:var(--color-muted); }
|
.gruppe-card-meta { font-size:0.78rem; color:var(--color-muted); }
|
||||||
.gruppe-card-actions { display:flex; gap:0.5rem; flex-wrap:wrap; margin-top:auto; padding-top:0.5rem; }
|
|
||||||
.gruppe-card-actions button, .gruppe-card-actions a.btn { margin-top:0; padding:0.3rem 0.7rem; font-size:0.8rem; width:auto; }
|
|
||||||
|
|
||||||
.role-badge { font-size:0.7rem; font-weight:700; padding:0.1rem 0.4rem; border-radius:4px; background:var(--color-primary); color:#fff; display:inline-block; }
|
.role-badge { font-size:0.7rem; font-weight:700; padding:0.1rem 0.4rem; border-radius:4px; background:var(--color-primary); color:#fff; display:inline-block; }
|
||||||
.role-badge.mitglied { background:var(--color-secondary); color:var(--color-text); }
|
.role-badge.mitglied { background:var(--color-secondary); color:var(--color-text); }
|
||||||
|
|
||||||
.search-row { display:flex; gap:0.75rem; margin-bottom:1rem; }
|
|
||||||
.search-row input { flex:1; }
|
|
||||||
.search-row button { white-space:nowrap; width:auto; margin-top:0; }
|
|
||||||
|
|
||||||
.anfrage-list { list-style:none; margin:0; padding:0; }
|
.anfrage-list { list-style:none; margin:0; padding:0; }
|
||||||
.anfrage-item { display:flex; align-items:center; justify-content:space-between; gap:1rem; padding:0.75rem 0; border-bottom:1px solid var(--color-secondary); }
|
.anfrage-item { display:flex; align-items:center; justify-content:space-between; gap:1rem; padding:0.75rem 0; border-bottom:1px solid var(--color-secondary); }
|
||||||
.anfrage-item:last-child { border-bottom:none; }
|
.anfrage-item:last-child { border-bottom:none; }
|
||||||
@@ -64,12 +57,14 @@
|
|||||||
<div class="content">
|
<div class="content">
|
||||||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:1.25rem;">
|
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:1.25rem;">
|
||||||
<h1 style="margin:0;">Gruppen</h1>
|
<h1 style="margin:0;">Gruppen</h1>
|
||||||
<button onclick="openCreateDialog()" style="width:auto; padding:0.4rem 1rem; font-size:0.9rem; margin:0;">+ Erstellen</button>
|
<div style="display:flex; gap:0.5rem;">
|
||||||
|
<button onclick="location.href='/search.html?tab=gruppen'" style="width:auto; padding:0.4rem 1rem; font-size:0.9rem; margin:0;">🔍 Suchen</button>
|
||||||
|
<button onclick="openCreateDialog()" style="width:auto; padding:0.4rem 1rem; font-size:0.9rem; margin:0;">+ Erstellen</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<button class="tab-btn active" data-tab="mine" onclick="switchTab('mine', this)">Meine Gruppen</button>
|
<button class="tab-btn active" data-tab="mine" onclick="switchTab('mine', this)">Meine Gruppen</button>
|
||||||
<button class="tab-btn" data-tab="discover" onclick="switchTab('discover', this)">Entdecken</button>
|
|
||||||
<button class="tab-btn" data-tab="requests" onclick="switchTab('requests', this)">Meine Anfragen</button>
|
<button class="tab-btn" data-tab="requests" onclick="switchTab('requests', this)">Meine Anfragen</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -79,16 +74,6 @@
|
|||||||
<p class="empty-hint" id="mineEmpty" style="display:none;">Du bist noch in keiner Gruppe.</p>
|
<p class="empty-hint" id="mineEmpty" style="display:none;">Du bist noch in keiner Gruppe.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Entdecken -->
|
|
||||||
<div class="tab-panel" id="tab-discover">
|
|
||||||
<div class="search-row">
|
|
||||||
<input type="text" id="searchInput" placeholder="Gruppenname suchen…" onkeydown="if(event.key==='Enter')doSearch()">
|
|
||||||
<button onclick="doSearch()">Suchen</button>
|
|
||||||
</div>
|
|
||||||
<div class="gruppe-grid" id="discoverGrid"></div>
|
|
||||||
<p class="empty-hint" id="discoverHint">Gib einen Suchbegriff ein.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Meine Anfragen -->
|
<!-- Meine Anfragen -->
|
||||||
<div class="tab-panel" id="tab-requests">
|
<div class="tab-panel" id="tab-requests">
|
||||||
<ul class="anfrage-list" id="requestsList"></ul>
|
<ul class="anfrage-list" id="requestsList"></ul>
|
||||||
@@ -121,20 +106,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Beitrittsanfrage Dialog -->
|
|
||||||
<div class="dialog-backdrop" id="joinDialog">
|
|
||||||
<div class="dialog">
|
|
||||||
<h3>Beitrittsanfrage senden</h3>
|
|
||||||
<p id="joinDialogGroupName" style="font-weight:600; margin-bottom:0.5rem;"></p>
|
|
||||||
<label>Nachricht (optional)</label>
|
|
||||||
<textarea id="joinNachricht" placeholder="Warum möchtest du beitreten?"></textarea>
|
|
||||||
<p class="message error" id="joinError" style="display:none; margin-top:0.75rem;"></p>
|
|
||||||
<div class="dialog-actions">
|
|
||||||
<button class="secondary" onclick="closeJoinDialog()">Abbrechen</button>
|
|
||||||
<button onclick="sendJoinRequest()">Anfrage senden</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="/js/icons.js"></script>
|
<script src="/js/icons.js"></script>
|
||||||
<script src="/js/nav.js"></script>
|
<script src="/js/nav.js"></script>
|
||||||
@@ -157,7 +128,7 @@
|
|||||||
|
|
||||||
function esc(s) { const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; }
|
function esc(s) { const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; }
|
||||||
|
|
||||||
function gruppeCard(g, showJoin = false) {
|
function gruppeCard(g) {
|
||||||
const img = g.bild
|
const img = g.bild
|
||||||
? `<div class="gruppe-card-img"><img src="data:image/jpeg;base64,${g.bild}" alt=""></div>`
|
? `<div class="gruppe-card-img"><img src="data:image/jpeg;base64,${g.bild}" alt=""></div>`
|
||||||
: `<div class="gruppe-card-img">👥</div>`;
|
: `<div class="gruppe-card-img">👥</div>`;
|
||||||
@@ -165,16 +136,6 @@
|
|||||||
? `<span class="role-badge ${g.myRole === 'ADMIN' ? '' : 'mitglied'}">${g.myRole === 'ADMIN' ? 'Admin' : 'Mitglied'}</span>`
|
? `<span class="role-badge ${g.myRole === 'ADMIN' ? '' : 'mitglied'}">${g.myRole === 'ADMIN' ? 'Admin' : 'Mitglied'}</span>`
|
||||||
: '';
|
: '';
|
||||||
const privBadge = g.isPrivate ? ' 🔒' : '';
|
const privBadge = g.isPrivate ? ' 🔒' : '';
|
||||||
let actions = '';
|
|
||||||
if (showJoin && !g.myRole) {
|
|
||||||
if (g.myRequestStatus === 'AUSSTEHEND') {
|
|
||||||
actions = `<button disabled style="opacity:0.6;" onclick="event.stopPropagation()">Anfrage ausstehend</button>`;
|
|
||||||
} else if (g.isPrivate) {
|
|
||||||
actions = `<button onclick="event.stopPropagation(); openJoinDialog('${g.gruppeId}','${esc(g.name)}')">Anfrage senden</button>`;
|
|
||||||
} else {
|
|
||||||
actions = `<button onclick="event.stopPropagation(); joinGruppe('${g.gruppeId}', this)">Beitreten</button>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return `
|
return `
|
||||||
<div class="gruppe-card" id="gc-${g.gruppeId}" onclick="location.href='/community/gruppe.html?gruppeId=${g.gruppeId}'" style="cursor:pointer;">
|
<div class="gruppe-card" id="gc-${g.gruppeId}" onclick="location.href='/community/gruppe.html?gruppeId=${g.gruppeId}'" style="cursor:pointer;">
|
||||||
${img}
|
${img}
|
||||||
@@ -183,7 +144,6 @@
|
|||||||
<div class="gruppe-card-meta">${g.memberCount} Mitglied${g.memberCount !== 1 ? 'er' : ''} · ${g.postCount} Beiträge</div>
|
<div class="gruppe-card-meta">${g.memberCount} Mitglied${g.memberCount !== 1 ? 'er' : ''} · ${g.postCount} Beiträge</div>
|
||||||
${g.beschreibung ? `<div style="font-size:0.82rem;color:var(--color-muted);">${esc(g.beschreibung.substring(0,80))}${g.beschreibung.length>80?'…':''}</div>` : ''}
|
${g.beschreibung ? `<div style="font-size:0.82rem;color:var(--color-muted);">${esc(g.beschreibung.substring(0,80))}${g.beschreibung.length>80?'…':''}</div>` : ''}
|
||||||
<div class="card-notif" id="notif-${g.gruppeId}"></div>
|
<div class="card-notif" id="notif-${g.gruppeId}"></div>
|
||||||
${actions ? `<div class="gruppe-card-actions">${actions}</div>` : ''}
|
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -223,37 +183,6 @@
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doSearch() {
|
|
||||||
const q = document.getElementById('searchInput').value.trim();
|
|
||||||
if (!q) return;
|
|
||||||
try {
|
|
||||||
const res = await fetch('/gruppen/search?q=' + encodeURIComponent(q));
|
|
||||||
if (!res.ok) return;
|
|
||||||
const data = await res.json();
|
|
||||||
const grid = document.getElementById('discoverGrid');
|
|
||||||
const hint = document.getElementById('discoverHint');
|
|
||||||
grid.innerHTML = '';
|
|
||||||
if (data.length === 0) { hint.textContent = 'Keine Gruppen gefunden.'; hint.style.display = ''; return; }
|
|
||||||
hint.style.display = 'none';
|
|
||||||
data.forEach(g => grid.insertAdjacentHTML('beforeend', gruppeCard(g, true)));
|
|
||||||
} catch(e) { console.error(e); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function joinGruppe(gruppeId, btn) {
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.textContent = '…';
|
|
||||||
try {
|
|
||||||
const res = await fetch('/gruppen/' + gruppeId + '/join', { method: 'POST', headers:{'Content-Type':'application/json'}, body:'{}' });
|
|
||||||
if (res.ok || res.status === 201) {
|
|
||||||
btn.textContent = 'Beigetreten ✓';
|
|
||||||
setTimeout(loadMine, 500);
|
|
||||||
} else {
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = 'Beitreten';
|
|
||||||
}
|
|
||||||
} catch(e) { btn.disabled = false; btn.textContent = 'Beitreten'; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadRequests() {
|
async function loadRequests() {
|
||||||
try {
|
try {
|
||||||
const reqRes = await fetch('/gruppen/requests/mine');
|
const reqRes = await fetch('/gruppen/requests/mine');
|
||||||
@@ -333,42 +262,6 @@
|
|||||||
} catch(e) { showCreateError('Fehler: ' + e.message); }
|
} catch(e) { showCreateError('Fehler: ' + e.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Join dialog ──
|
|
||||||
|
|
||||||
let pendingJoinGruppeId = null;
|
|
||||||
function openJoinDialog(gruppeId, name) {
|
|
||||||
pendingJoinGruppeId = gruppeId;
|
|
||||||
document.getElementById('joinDialogGroupName').textContent = name;
|
|
||||||
document.getElementById('joinNachricht').value = '';
|
|
||||||
document.getElementById('joinDialog').classList.add('visible');
|
|
||||||
}
|
|
||||||
function closeJoinDialog() { document.getElementById('joinDialog').classList.remove('visible'); pendingJoinGruppeId = null; }
|
|
||||||
|
|
||||||
async function sendJoinRequest() {
|
|
||||||
if (!pendingJoinGruppeId) return;
|
|
||||||
document.getElementById('joinError').style.display = 'none';
|
|
||||||
const nachricht = document.getElementById('joinNachricht').value.trim() || null;
|
|
||||||
try {
|
|
||||||
const res = await fetch('/gruppen/' + pendingJoinGruppeId + '/join', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type':'application/json'},
|
|
||||||
body: JSON.stringify({ nachricht })
|
|
||||||
});
|
|
||||||
if (res.ok || res.status === 201) {
|
|
||||||
closeJoinDialog();
|
|
||||||
doSearch();
|
|
||||||
} else {
|
|
||||||
const el = document.getElementById('joinError');
|
|
||||||
el.textContent = 'Fehler beim Senden der Anfrage.';
|
|
||||||
el.style.display = 'block';
|
|
||||||
}
|
|
||||||
} catch(e) {
|
|
||||||
const el = document.getElementById('joinError');
|
|
||||||
el.textContent = 'Fehler: ' + e.message;
|
|
||||||
el.style.display = 'block';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Image preview ──
|
// ── Image preview ──
|
||||||
|
|
||||||
function previewBild(input, previewId, dataId) {
|
function previewBild(input, previewId, dataId) {
|
||||||
@@ -401,9 +294,6 @@
|
|||||||
document.getElementById('createDialog').addEventListener('click', e => {
|
document.getElementById('createDialog').addEventListener('click', e => {
|
||||||
if (e.target === document.getElementById('createDialog')) closeCreateDialog();
|
if (e.target === document.getElementById('createDialog')) closeCreateDialog();
|
||||||
});
|
});
|
||||||
document.getElementById('joinDialog').addEventListener('click', e => {
|
|
||||||
if (e.target === document.getElementById('joinDialog')) closeJoinDialog();
|
|
||||||
});
|
|
||||||
|
|
||||||
loadMine();
|
loadMine();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -360,7 +360,7 @@
|
|||||||
const list = document.getElementById('convList');
|
const list = document.getElementById('convList');
|
||||||
list.innerHTML = '';
|
list.innerHTML = '';
|
||||||
if (convs.length === 0) {
|
if (convs.length === 0) {
|
||||||
list.innerHTML = '<li style="padding:1rem; color:var(--color-muted); font-size:0.9rem;">Noch keine Nachrichten. <a href="/community/personen-suchen.html" style="color:var(--color-primary);">Personen suchen</a></li>';
|
list.innerHTML = '<li style="padding:1rem; color:var(--color-muted); font-size:0.9rem;">Noch keine Nachrichten. <a href="/search.html" style="color:var(--color-primary);">Personen suchen</a></li>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
convs.forEach(c => {
|
convs.forEach(c => {
|
||||||
|
|||||||
@@ -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);
|
const id = addPlayer(p.name, i === 0, i === 0, false, false);
|
||||||
if (id) { restorePlayer(id, p); if (p.userId) userIdToInfo[p.userId] = { playerId: id, name: p.name }; }
|
if (id) { restorePlayer(id, p); if (p.userId) userIdToInfo[p.userId] = { playerId: id, name: p.name }; }
|
||||||
});
|
});
|
||||||
|
Object.entries(userIdToInfo).forEach(([userId, info]) => { pruefeChastityConstraint(info.playerId, userId); });
|
||||||
await ladeEinladungenAusDb(userIdToInfo);
|
await ladeEinladungenAusDb(userIdToInfo);
|
||||||
restoredFromSetup = true;
|
restoredFromSetup = true;
|
||||||
} else {
|
} else {
|
||||||
@@ -1273,14 +1274,15 @@
|
|||||||
if (draft.gruppenJson) { sessionStorage.setItem('vanilla-session-gruppen', draft.gruppenJson); savedGruppen = new Set(JSON.parse(draft.gruppenJson)); }
|
if (draft.gruppenJson) { sessionStorage.setItem('vanilla-session-gruppen', draft.gruppenJson); savedGruppen = new Set(JSON.parse(draft.gruppenJson)); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const selfGeschlecht = user?.geschlecht || null;
|
const defaults = await fetch('/user/me/bdsm-defaults').then(r => r.ok ? r.json() : {}).catch(() => ({}));
|
||||||
const selfWerkzeuge = selfGeschlecht ? (WERKZEUGE_DEFAULTS[selfGeschlecht] || []) : [];
|
const selfGeschlecht = defaults.geschlecht || user?.geschlecht || null;
|
||||||
|
const selfWerkzeuge = defaults.werkzeuge?.length ? defaults.werkzeuge : (selfGeschlecht ? (WERKZEUGE_DEFAULTS[selfGeschlecht] || []) : []);
|
||||||
const selfId = addPlayer(user ? user.name : '', true, true, !!selfGeschlecht, false);
|
const selfId = addPlayer(user ? user.name : '', true, true, !!selfGeschlecht, false);
|
||||||
if (selfId) {
|
if (selfId) {
|
||||||
if (user?.profilePicture) { playerProfilePics[selfId] = user.profilePicture; updatePlayerHeader(selfId, user.name); }
|
if (user?.profilePicture) { playerProfilePics[selfId] = user.profilePicture; updatePlayerHeader(selfId, user.name); }
|
||||||
if (playerIds.length < MAX_PLAYERS) addPlayer();
|
if (playerIds.length < MAX_PLAYERS) addPlayer();
|
||||||
restorePlayer(selfId, { geschlecht: selfGeschlecht, spieltMit: [], rollen: [], werkzeuge: selfWerkzeuge });
|
restorePlayer(selfId, { geschlecht: selfGeschlecht, spieltMit: defaults.spieltMit || [], rollen: defaults.rollen || [], werkzeuge: selfWerkzeuge });
|
||||||
pruefeChastityConstraint(selfId, myUserId);
|
if (myUserId) pruefeChastityConstraint(selfId, myUserId);
|
||||||
}
|
}
|
||||||
await ladeEinladungenAusDb(null);
|
await ladeEinladungenAusDb(null);
|
||||||
}
|
}
|
||||||
@@ -1324,8 +1326,9 @@
|
|||||||
|
|
||||||
if (user?.profilePicture) { playerProfilePics[guestOwnPlayerId] = user.profilePicture; updatePlayerHeader(guestOwnPlayerId, user?.name || ''); }
|
if (user?.profilePicture) { playerProfilePics[guestOwnPlayerId] = user.profilePicture; updatePlayerHeader(guestOwnPlayerId, user?.name || ''); }
|
||||||
|
|
||||||
restorePlayer(guestOwnPlayerId, { geschlecht: user?.geschlecht || null, spieltMit: [], rollen: [], werkzeuge: [] });
|
const defaults = await fetch('/user/me/bdsm-defaults').then(r => r.ok ? r.json() : {}).catch(() => ({}));
|
||||||
pruefeChastityConstraint(guestOwnPlayerId, myUserId);
|
restorePlayer(guestOwnPlayerId, { geschlecht: defaults.geschlecht || user?.geschlecht || null, spieltMit: defaults.spieltMit || [], rollen: defaults.rollen || [], werkzeuge: defaults.werkzeuge || [] });
|
||||||
|
if (myUserId) pruefeChastityConstraint(guestOwnPlayerId, myUserId);
|
||||||
|
|
||||||
document.getElementById('acc-grundeinstellungen-btn').classList.add('is-open');
|
document.getElementById('acc-grundeinstellungen-btn').classList.add('is-open');
|
||||||
document.getElementById('acc-grundeinstellungen-body').classList.add('is-open');
|
document.getElementById('acc-grundeinstellungen-body').classList.add('is-open');
|
||||||
|
|||||||
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);
|
box-shadow: 0 2px 12px rgba(0,0,0,0.4);
|
||||||
z-index: 500;
|
z-index: 500;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 0.25rem;
|
padding: 0 4px 0 0.25rem;
|
||||||
}
|
}
|
||||||
.mobile-topbar-logo {
|
.mobile-topbar-logo {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -43,9 +43,9 @@
|
|||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
font-size: 1.725rem;
|
font-size: 1.3rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
padding: 0.75rem 0.675rem;
|
padding: 0.55rem 0.6rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -301,7 +301,7 @@
|
|||||||
'/community/gruppen.html', '/community/gruppe.html',
|
'/community/gruppen.html', '/community/gruppe.html',
|
||||||
'/community/locations.html', '/community/location-detail.html',
|
'/community/locations.html', '/community/location-detail.html',
|
||||||
'/community/events.html', '/community/event-detail.html',
|
'/community/events.html', '/community/event-detail.html',
|
||||||
'/community/abonnements.html', '/community/personen-suchen.html',
|
'/community/abonnements.html',
|
||||||
'/community/benutzer.html',
|
'/community/benutzer.html',
|
||||||
])}
|
])}
|
||||||
${column('colDating', 'Dating', col3Html, ['/dating/'])}
|
${column('colDating', 'Dating', col3Html, ['/dating/'])}
|
||||||
|
|||||||
@@ -5,12 +5,19 @@
|
|||||||
|
|
||||||
// ── Bereichs-Definitionen ────────────────────────────────────────────────
|
// ── Bereichs-Definitionen ────────────────────────────────────────────────
|
||||||
const SECTIONS = {
|
const SECTIONS = {
|
||||||
|
common: {
|
||||||
|
prefixes: ['/games/common/'],
|
||||||
|
items: [
|
||||||
|
{ href: '/games/vanilla/neuvanilla.html', icon: 'VANILLA', label: 'Vanilla Game' },
|
||||||
|
{ href: '/games/bdsm/neubdsm.html', icon: 'BDSM', label: 'BDSM Game' },
|
||||||
|
{ href: '/games/chastity/neulock.html', icon: 'CHASTITY', label: 'Chastity Game' },
|
||||||
|
],
|
||||||
|
},
|
||||||
social: {
|
social: {
|
||||||
prefixes: ['/community/'],
|
prefixes: ['/community/'],
|
||||||
exclude: [
|
exclude: [
|
||||||
'/community/nachrichten.html',
|
'/community/nachrichten.html',
|
||||||
'/community/benachrichtigungen.html',
|
'/community/benachrichtigungen.html',
|
||||||
'/community/einladungen.html',
|
|
||||||
],
|
],
|
||||||
items: [
|
items: [
|
||||||
{ href: '/community/feed.html', icon: 'FEED', label: 'Feed' },
|
{ href: '/community/feed.html', icon: 'FEED', label: 'Feed' },
|
||||||
|
|||||||
@@ -168,18 +168,26 @@
|
|||||||
|
|
||||||
async function doSearch(q, overlay) {
|
async function doSearch(q, overlay) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/search?q=' + encodeURIComponent(q) + '&limit=3');
|
const tagQuery = q.startsWith('#') ? q.slice(1) : q;
|
||||||
if (!res.ok) { overlay.innerHTML = '<div class="topbar-search-hint">Fehler bei der Suche.</div>'; return; }
|
const [searchRes, gruppenRes, hashtagRes] = await Promise.all([
|
||||||
const data = await res.json();
|
fetch('/search?q=' + encodeURIComponent(q) + '&limit=3'),
|
||||||
const { users = [], locations = [], events = [] } = data;
|
fetch('/gruppen/search?q=' + encodeURIComponent(q)),
|
||||||
|
fetch('/hashtags/suggest?q=' + encodeURIComponent(tagQuery) + '&limit=4')
|
||||||
|
]);
|
||||||
|
|
||||||
if (!users.length && !locations.length && !events.length) {
|
const data = searchRes.ok ? await searchRes.json() : {};
|
||||||
overlay.innerHTML = '<div class="topbar-search-hint">Keine Ergebnisse.</div>';
|
const gruppen = gruppenRes.ok ? await gruppenRes.json() : [];
|
||||||
return;
|
const hashtags = hashtagRes.ok ? await hashtagRes.json() : [];
|
||||||
}
|
|
||||||
|
const { users = [], locations = [], events = [] } = data;
|
||||||
|
const gruppenSlice = gruppen.slice(0, 3);
|
||||||
|
|
||||||
let html = '';
|
let html = '';
|
||||||
|
|
||||||
|
if (!users.length && !locations.length && !events.length && !gruppenSlice.length && !hashtags.length) {
|
||||||
|
html += '<div class="topbar-search-hint">Keine Ergebnisse.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
if (users.length) {
|
if (users.length) {
|
||||||
html += `<div class="topbar-search-section">Personen</div>`;
|
html += `<div class="topbar-search-section">Personen</div>`;
|
||||||
html += users.map(u => {
|
html += users.map(u => {
|
||||||
@@ -213,6 +221,26 @@
|
|||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (gruppenSlice.length) {
|
||||||
|
html += `<div class="topbar-search-section">Gruppen</div>`;
|
||||||
|
html += gruppenSlice.map(g => {
|
||||||
|
const av = g.bild
|
||||||
|
? `<img src="data:image/jpeg;base64,${esc(g.bild)}" class="topbar-search-avatar" alt="">`
|
||||||
|
: `<span class="topbar-search-avatar topbar-search-avatar--placeholder">👥</span>`;
|
||||||
|
return `<a href="/community/gruppe.html?gruppeId=${esc(g.gruppeId)}" class="topbar-search-result">
|
||||||
|
${av}<span>${esc(g.name)}</span></a>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hashtags.length) {
|
||||||
|
html += `<div class="topbar-search-section">Hashtags</div>`;
|
||||||
|
html += `<div style="display:flex;flex-wrap:wrap;gap:0.4rem;padding:0.4rem 0.75rem;">`;
|
||||||
|
html += hashtags.map(tag =>
|
||||||
|
`<a href="/community/feed.html?tag=${encodeURIComponent(tag)}" style="display:inline-block;padding:0.25rem 0.65rem;background:var(--color-secondary);border-radius:14px;font-size:0.82rem;font-weight:600;color:var(--color-primary);text-decoration:none;">#${esc(tag)}</a>`
|
||||||
|
).join('');
|
||||||
|
html += `</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
html += `<a href="/search.html?q=${encodeURIComponent(q)}" class="topbar-search-all">Alle Ergebnisse anzeigen →</a>`;
|
html += `<a href="/search.html?q=${encodeURIComponent(q)}" class="topbar-search-all">Alle Ergebnisse anzeigen →</a>`;
|
||||||
overlay.innerHTML = html;
|
overlay.innerHTML = html;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -163,6 +163,33 @@
|
|||||||
transition: border-color 0.15s, color 0.15s;
|
transition: border-color 0.15s, color 0.15s;
|
||||||
}
|
}
|
||||||
.search-load-more:hover { border-color: var(--color-primary); color: var(--color-primary); background: none; }
|
.search-load-more:hover { border-color: var(--color-primary); color: var(--color-primary); background: none; }
|
||||||
|
|
||||||
|
.hashtag-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.4rem 0.85rem;
|
||||||
|
background: var(--color-card);
|
||||||
|
border: 1px solid var(--color-secondary);
|
||||||
|
border-radius: 20px;
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.hashtag-chip:hover { border-color: var(--color-primary); background: var(--color-secondary); }
|
||||||
|
|
||||||
|
/* Dialog (Gruppen-Beitritt) */
|
||||||
|
.dialog-backdrop { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.6); z-index:200; align-items:center; justify-content:center; }
|
||||||
|
.dialog-backdrop.visible { display:flex; }
|
||||||
|
.dialog { background:var(--color-card); border:1px solid var(--color-secondary); border-radius:12px; padding:1.75rem; width:100%; max-width:420px; box-shadow:0 8px 32px rgba(0,0,0,0.6); max-height:90vh; overflow-y:auto; }
|
||||||
|
.dialog h3 { color:var(--color-primary); font-size:1.1rem; margin-bottom:1.25rem; }
|
||||||
|
.dialog label { display:block; font-size:0.8rem; color:#aaa; margin-bottom:0.3rem; margin-top:1rem; }
|
||||||
|
.dialog textarea { width:100%; padding:0.6rem 0.85rem; border:1px solid var(--color-secondary); border-radius:6px; background:var(--color-secondary); color:var(--color-text); font-size:0.95rem; outline:none; transition:border-color 0.2s; resize:vertical; min-height:80px; box-sizing:border-box; }
|
||||||
|
.dialog textarea:focus { border-color:var(--color-primary); }
|
||||||
|
.dialog-actions { display:flex; justify-content:flex-end; gap:0.75rem; margin-top:1.5rem; }
|
||||||
|
.dialog-actions button { flex:none; margin:0; padding:0.55rem 1.1rem; font-size:0.9rem; width:auto; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="app">
|
<body class="app">
|
||||||
@@ -173,7 +200,7 @@
|
|||||||
<div class="search-hero">
|
<div class="search-hero">
|
||||||
<div class="search-hero-input-wrap">
|
<div class="search-hero-input-wrap">
|
||||||
<span class="search-hero-icon" id="searchIcon"></span>
|
<span class="search-hero-icon" id="searchIcon"></span>
|
||||||
<input type="text" id="searchInput" placeholder="Suchen nach Personen, Locations, Veranstaltungen…"
|
<input type="text" id="searchInput" placeholder="Suchen nach Personen, Locations, Veranstaltungen, Gruppen…"
|
||||||
autocomplete="off" spellcheck="false">
|
autocomplete="off" spellcheck="false">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -188,6 +215,12 @@
|
|||||||
<button class="search-tab-btn" data-tab="events">
|
<button class="search-tab-btn" data-tab="events">
|
||||||
Veranstaltungen <span class="search-tab-count" id="countEvents">0</span>
|
Veranstaltungen <span class="search-tab-count" id="countEvents">0</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="search-tab-btn" data-tab="gruppen">
|
||||||
|
Gruppen <span class="search-tab-count" id="countGruppen">0</span>
|
||||||
|
</button>
|
||||||
|
<button class="search-tab-btn" data-tab="hashtags">
|
||||||
|
Hashtags <span class="search-tab-count" id="countHashtags">0</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="search-panel active" id="panel-users">
|
<div class="search-panel active" id="panel-users">
|
||||||
@@ -207,6 +240,31 @@
|
|||||||
<div class="search-grid" id="gridEvents"></div>
|
<div class="search-grid" id="gridEvents"></div>
|
||||||
<button class="search-load-more" id="moreEvents" style="display:none;">Mehr laden</button>
|
<button class="search-load-more" id="moreEvents" style="display:none;">Mehr laden</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="search-panel" id="panel-gruppen">
|
||||||
|
<div class="search-loading" id="loadingGruppen" style="display:none;">Wird geladen…</div>
|
||||||
|
<div class="search-grid" id="gridGruppen"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="search-panel" id="panel-hashtags">
|
||||||
|
<div class="search-loading" id="loadingHashtags" style="display:none;">Wird geladen…</div>
|
||||||
|
<div id="gridHashtags" style="display:flex; flex-wrap:wrap; gap:0.6rem; padding-top:0.25rem;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Beitrittsanfrage Dialog -->
|
||||||
|
<div class="dialog-backdrop" id="searchJoinDialog">
|
||||||
|
<div class="dialog">
|
||||||
|
<h3>Beitrittsanfrage senden</h3>
|
||||||
|
<p id="searchJoinGroupName" style="font-weight:600; margin-bottom:0.5rem;"></p>
|
||||||
|
<label>Nachricht (optional)</label>
|
||||||
|
<textarea id="searchJoinNachricht" placeholder="Warum möchtest du beitreten?"></textarea>
|
||||||
|
<p class="message error" id="searchJoinError" style="display:none; margin-top:0.75rem;"></p>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button class="secondary" id="searchJoinCancelBtn">Abbrechen</button>
|
||||||
|
<button id="searchJoinSendBtn">Anfrage senden</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -279,10 +337,12 @@
|
|||||||
document.getElementById('grid' + cap(t)).innerHTML = '';
|
document.getElementById('grid' + cap(t)).innerHTML = '';
|
||||||
document.getElementById('more' + cap(t)).style.display = 'none';
|
document.getElementById('more' + cap(t)).style.display = 'none';
|
||||||
});
|
});
|
||||||
// Alle drei Typen parallel laden
|
// Alle Typen parallel laden
|
||||||
loadChunk('users');
|
loadChunk('users');
|
||||||
loadChunk('locations');
|
loadChunk('locations');
|
||||||
loadChunk('events');
|
loadChunk('events');
|
||||||
|
loadGruppen(q);
|
||||||
|
loadHashtags(q);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearAll() {
|
function clearAll() {
|
||||||
@@ -295,6 +355,12 @@
|
|||||||
document.getElementById('count' + cap(t)).textContent = '0';
|
document.getElementById('count' + cap(t)).textContent = '0';
|
||||||
document.getElementById('loading' + cap(t)).style.display = 'none';
|
document.getElementById('loading' + cap(t)).style.display = 'none';
|
||||||
});
|
});
|
||||||
|
document.getElementById('gridGruppen').innerHTML = '';
|
||||||
|
document.getElementById('countGruppen').textContent = '0';
|
||||||
|
document.getElementById('loadingGruppen').style.display = 'none';
|
||||||
|
document.getElementById('gridHashtags').innerHTML = '';
|
||||||
|
document.getElementById('countHashtags').textContent = '0';
|
||||||
|
document.getElementById('loadingHashtags').style.display = 'none';
|
||||||
const url = new URL(window.location);
|
const url = new URL(window.location);
|
||||||
url.searchParams.delete('q');
|
url.searchParams.delete('q');
|
||||||
history.replaceState(null, '', url);
|
history.replaceState(null, '', url);
|
||||||
@@ -374,14 +440,185 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Gruppen-Suche ──
|
||||||
|
|
||||||
|
async function loadGruppen(q) {
|
||||||
|
const loadingEl = document.getElementById('loadingGruppen');
|
||||||
|
const grid = document.getElementById('gridGruppen');
|
||||||
|
loadingEl.style.display = '';
|
||||||
|
grid.innerHTML = '';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/gruppen/search?q=' + encodeURIComponent(q));
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
const data = await res.json();
|
||||||
|
document.getElementById('countGruppen').textContent = data.length;
|
||||||
|
if (!data.length) {
|
||||||
|
grid.innerHTML = '<div class="search-empty" style="grid-column:1/-1;">Keine Ergebnisse.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
data.forEach(g => grid.appendChild(buildGruppeCard(g)));
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
loadingEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGruppeCard(g) {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'search-card';
|
||||||
|
card.style.cssText = 'justify-content:space-between; cursor:pointer;';
|
||||||
|
card.addEventListener('click', () => { location.href = '/community/gruppe.html?gruppeId=' + g.gruppeId; });
|
||||||
|
|
||||||
|
const av = g.bild
|
||||||
|
? `<div class="search-card-avatar search-card-avatar--square"><img src="data:image/jpeg;base64,${esc(g.bild)}" alt=""></div>`
|
||||||
|
: `<div class="search-card-avatar search-card-avatar--square">👥</div>`;
|
||||||
|
const privBadge = g.isPrivate ? ' 🔒' : '';
|
||||||
|
const sub = g.memberCount + ' Mitglied' + (g.memberCount !== 1 ? 'er' : '');
|
||||||
|
|
||||||
|
const info = document.createElement('div');
|
||||||
|
info.style.cssText = 'display:flex; align-items:center; gap:0.75rem; min-width:0; flex:1;';
|
||||||
|
info.innerHTML = `${av}<div class="search-card-body"><div class="search-card-name">${esc(g.name)}${privBadge}</div><div class="search-card-sub">${esc(sub)}</div></div>`;
|
||||||
|
card.appendChild(info);
|
||||||
|
|
||||||
|
if (!g.myRole) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.style.cssText = 'font-size:0.78rem; padding:0.3rem 0.65rem; width:auto; margin:0; white-space:nowrap; flex-shrink:0; margin-left:0.5rem;';
|
||||||
|
if (g.myRequestStatus === 'AUSSTEHEND') {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.style.opacity = '0.6';
|
||||||
|
btn.textContent = 'Anfrage ausstehend';
|
||||||
|
} else if (g.isPrivate) {
|
||||||
|
btn.textContent = 'Anfrage senden';
|
||||||
|
btn.addEventListener('click', e => { e.stopPropagation(); openSearchJoinDialog(g.gruppeId, g.name); });
|
||||||
|
} else {
|
||||||
|
btn.textContent = 'Beitreten';
|
||||||
|
btn.addEventListener('click', e => { e.stopPropagation(); joinGruppeSearch(g.gruppeId, btn); });
|
||||||
|
}
|
||||||
|
card.appendChild(btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hashtag-Suche ──
|
||||||
|
|
||||||
|
async function loadHashtags(q) {
|
||||||
|
const loadingEl = document.getElementById('loadingHashtags');
|
||||||
|
const grid = document.getElementById('gridHashtags');
|
||||||
|
loadingEl.style.display = '';
|
||||||
|
grid.innerHTML = '';
|
||||||
|
try {
|
||||||
|
const raw = q.startsWith('#') ? q.slice(1) : q;
|
||||||
|
const res = await fetch('/hashtags/suggest?q=' + encodeURIComponent(raw) + '&limit=20');
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
const tags = await res.json();
|
||||||
|
document.getElementById('countHashtags').textContent = tags.length;
|
||||||
|
if (!tags.length) {
|
||||||
|
grid.innerHTML = '<div class="search-empty">Keine Hashtags gefunden.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tags.forEach(tag => {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.className = 'hashtag-chip';
|
||||||
|
a.href = '/community/feed.html?tag=' + encodeURIComponent(tag);
|
||||||
|
a.textContent = '#' + tag;
|
||||||
|
grid.appendChild(a);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
loadingEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function joinGruppeSearch(gruppeId, btn) {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '…';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/gruppen/' + gruppeId + '/join', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: '{}'
|
||||||
|
});
|
||||||
|
if (res.ok || res.status === 201) {
|
||||||
|
btn.textContent = 'Beigetreten ✓';
|
||||||
|
} else {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Beitreten';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Beitreten';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Join-Dialog für Gruppen ──
|
||||||
|
|
||||||
|
let searchJoinGruppeId = null;
|
||||||
|
|
||||||
|
function openSearchJoinDialog(gruppeId, name) {
|
||||||
|
searchJoinGruppeId = gruppeId;
|
||||||
|
document.getElementById('searchJoinGroupName').textContent = name;
|
||||||
|
document.getElementById('searchJoinNachricht').value = '';
|
||||||
|
document.getElementById('searchJoinError').style.display = 'none';
|
||||||
|
document.getElementById('searchJoinDialog').classList.add('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSearchJoinDialog() {
|
||||||
|
document.getElementById('searchJoinDialog').classList.remove('visible');
|
||||||
|
searchJoinGruppeId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendSearchJoinRequest() {
|
||||||
|
if (!searchJoinGruppeId) return;
|
||||||
|
document.getElementById('searchJoinError').style.display = 'none';
|
||||||
|
const nachricht = document.getElementById('searchJoinNachricht').value.trim() || null;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/gruppen/' + searchJoinGruppeId + '/join', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ nachricht })
|
||||||
|
});
|
||||||
|
if (res.ok || res.status === 201) {
|
||||||
|
closeSearchJoinDialog();
|
||||||
|
if (currentQuery.length >= 2) loadGruppen(currentQuery);
|
||||||
|
} else {
|
||||||
|
const el = document.getElementById('searchJoinError');
|
||||||
|
el.textContent = 'Fehler beim Senden der Anfrage.';
|
||||||
|
el.style.display = 'block';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const el = document.getElementById('searchJoinError');
|
||||||
|
el.textContent = 'Fehler: ' + e.message;
|
||||||
|
el.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('searchJoinCancelBtn').addEventListener('click', closeSearchJoinDialog);
|
||||||
|
document.getElementById('searchJoinSendBtn').addEventListener('click', sendSearchJoinRequest);
|
||||||
|
document.getElementById('searchJoinDialog').addEventListener('click', e => {
|
||||||
|
if (e.target === document.getElementById('searchJoinDialog')) closeSearchJoinDialog();
|
||||||
|
});
|
||||||
|
|
||||||
function cap(s) { return s.charAt(0).toUpperCase() + s.slice(1); }
|
function cap(s) { return s.charAt(0).toUpperCase() + s.slice(1); }
|
||||||
|
|
||||||
// ── URL-Parameter beim Start auslesen ──
|
// ── URL-Parameter beim Start auslesen ──
|
||||||
const initQ = new URLSearchParams(window.location.search).get('q') || '';
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const initQ = params.get('q') || '';
|
||||||
|
const initTab = params.get('tab') || '';
|
||||||
|
|
||||||
|
if (initTab) {
|
||||||
|
const tabBtn = document.querySelector(`.search-tab-btn[data-tab="${initTab}"]`);
|
||||||
|
if (tabBtn) tabBtn.click();
|
||||||
|
}
|
||||||
|
|
||||||
if (initQ.length >= 2) {
|
if (initQ.length >= 2) {
|
||||||
input.value = initQ;
|
input.value = initQ;
|
||||||
// Warten bis icons.js geladen ist
|
|
||||||
setTimeout(() => startSearch(initQ), 100);
|
setTimeout(() => startSearch(initQ), 100);
|
||||||
|
} else if (initTab === 'hashtags') {
|
||||||
|
// Populäre Tags zeigen wenn kein Suchbegriff
|
||||||
|
setTimeout(() => loadHashtags(''), 100);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -243,6 +243,33 @@
|
|||||||
.lb-comment-compose button { width:auto; margin:0; padding:0.35rem 0.75rem; font-size:0.8rem; }
|
.lb-comment-compose button { width:auto; margin:0; padding:0.35rem 0.75rem; font-size:0.8rem; }
|
||||||
@media (max-width:650px) { .lb-layout { flex-direction:column; height:95vh; } .lb-post-side { border-right:none; border-bottom:1px solid var(--color-secondary); max-height:55vh; } .lb-comments-panel { width:100%; } }
|
@media (max-width:650px) { .lb-layout { flex-direction:column; height:95vh; } .lb-post-side { border-right:none; border-bottom:1px solid var(--color-secondary); max-height:55vh; } .lb-comments-panel { width:100%; } }
|
||||||
|
|
||||||
|
/* ── Spiel starten ── */
|
||||||
|
.start-game-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.start-game-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
background: var(--color-card);
|
||||||
|
border: 1px solid var(--color-secondary);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.25rem 1rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--color-text);
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.start-game-card:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
background: rgba(var(--color-primary-rgb,233,69,96),0.06);
|
||||||
|
}
|
||||||
|
.start-game-icon { font-size: 2rem; line-height: 1; }
|
||||||
|
.start-game-title { font-size: 0.9rem; font-weight: 600; }
|
||||||
|
|
||||||
/* ── Neue Mitglieder ── */
|
/* ── Neue Mitglieder ── */
|
||||||
.new-members-strip {
|
.new-members-strip {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -306,6 +333,25 @@
|
|||||||
<div class="active-game-list" id="activeLockList"></div>
|
<div class="active-game-list" id="activeLockList"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Kein Spiel aktiv – Starten -->
|
||||||
|
<div id="startGameSection" style="display:none;">
|
||||||
|
<div class="section-label">Spiel starten 🎮</div>
|
||||||
|
<div class="start-game-grid">
|
||||||
|
<a href="/games/vanilla/neuvanilla.html" class="start-game-card">
|
||||||
|
<div class="start-game-icon">🎭</div>
|
||||||
|
<div class="start-game-title">Vanilla Game starten</div>
|
||||||
|
</a>
|
||||||
|
<a href="/games/bdsm/neubdsm.html" class="start-game-card">
|
||||||
|
<div class="start-game-icon">⛓</div>
|
||||||
|
<div class="start-game-title">BDSM-Game starten</div>
|
||||||
|
</a>
|
||||||
|
<a href="/games/chastity/neulock.html" class="start-game-card">
|
||||||
|
<div class="start-game-icon">🔒</div>
|
||||||
|
<div class="start-game-title">Chastity-Lock starten</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Einladungen -->
|
<!-- Einladungen -->
|
||||||
<div id="invitesSection" style="display:none;">
|
<div id="invitesSection" style="display:none;">
|
||||||
<div class="section-label">Einladungen 📨</div>
|
<div class="section-label">Einladungen 📨</div>
|
||||||
@@ -423,6 +469,7 @@
|
|||||||
|
|
||||||
<script src="/js/shared.js"></script>
|
<script src="/js/shared.js"></script>
|
||||||
<script src="/js/icons.js"></script>
|
<script src="/js/icons.js"></script>
|
||||||
|
<script src="/js/hashtag.js"></script>
|
||||||
<script src="/js/nav.js"></script>
|
<script src="/js/nav.js"></script>
|
||||||
<script>
|
<script>
|
||||||
let myUserId = null;
|
let myUserId = null;
|
||||||
@@ -436,14 +483,20 @@
|
|||||||
if (user) {
|
if (user) {
|
||||||
myUserId = user.userId;
|
myUserId = user.userId;
|
||||||
document.getElementById('greeting').textContent = 'Willkommen zurück, ' + user.name + '!';
|
document.getElementById('greeting').textContent = 'Willkommen zurück, ' + user.name + '!';
|
||||||
loadActiveGames(user.userId);
|
Promise.all([loadActiveGames(user.userId), loadActiveLock()]).then(() => {
|
||||||
loadActiveLock();
|
const hasGames = document.getElementById('activeGamesSection').style.display !== 'none';
|
||||||
|
const hasLock = document.getElementById('activeLockSection').style.display !== 'none';
|
||||||
|
if (!hasGames && !hasLock) {
|
||||||
|
document.getElementById('startGameSection').style.display = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
loadInvites();
|
loadInvites();
|
||||||
loadFriendRequests();
|
loadFriendRequests();
|
||||||
loadVisitors();
|
loadVisitors();
|
||||||
loadMyEvents();
|
loadMyEvents();
|
||||||
loadLocEvents();
|
loadLocEvents();
|
||||||
loadFeed();
|
loadFeed();
|
||||||
|
attachHashtagAutocomplete(document.getElementById('homeComposeText'));
|
||||||
if (user.datingAktiv) {
|
if (user.datingAktiv) {
|
||||||
loadWhoLikesMe();
|
loadWhoLikesMe();
|
||||||
loadMatches();
|
loadMatches();
|
||||||
@@ -931,7 +984,7 @@
|
|||||||
<div class="post-meta">${fmtDate(p.createdAt)}${groupBadge}</div>
|
<div class="post-meta">${fmtDate(p.createdAt)}${groupBadge}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="post-text">${esc(p.text || '')}</div>
|
<div class="post-text">${renderTextWithHashtags(p.text || '')}</div>
|
||||||
${bildHtml}
|
${bildHtml}
|
||||||
${umfrageHtml}
|
${umfrageHtml}
|
||||||
<div class="post-actions">
|
<div class="post-actions">
|
||||||
|
|||||||
Reference in New Issue
Block a user