diff --git a/.gitignore b/.gitignore index da56994..2d6aca8 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ # Ignore Gradle build output directory build .aider* + +# Secrets – niemals einchecken +.env diff --git a/bin/main/de/oaa/xxx/config/SecurityConfig.class b/bin/main/de/oaa/xxx/config/SecurityConfig.class index 740f6aa..dee7a62 100644 Binary files a/bin/main/de/oaa/xxx/config/SecurityConfig.class and b/bin/main/de/oaa/xxx/config/SecurityConfig.class differ diff --git a/bin/main/de/oaa/xxx/feed/FeedController$FeedPage.class b/bin/main/de/oaa/xxx/feed/FeedController$FeedPage.class index 61b7dd5..f1930d3 100644 Binary files a/bin/main/de/oaa/xxx/feed/FeedController$FeedPage.class and b/bin/main/de/oaa/xxx/feed/FeedController$FeedPage.class differ diff --git a/bin/main/de/oaa/xxx/feed/FeedController$VoteRequest.class b/bin/main/de/oaa/xxx/feed/FeedController$VoteRequest.class index 8bf8cb7..616c577 100644 Binary files a/bin/main/de/oaa/xxx/feed/FeedController$VoteRequest.class and b/bin/main/de/oaa/xxx/feed/FeedController$VoteRequest.class differ diff --git a/bin/main/de/oaa/xxx/feed/FeedController.class b/bin/main/de/oaa/xxx/feed/FeedController.class index a360759..daaeaf9 100644 Binary files a/bin/main/de/oaa/xxx/feed/FeedController.class and b/bin/main/de/oaa/xxx/feed/FeedController.class differ diff --git a/bin/main/de/oaa/xxx/gruppe/GruppenbeitragController$CreateBeitragRequest.class b/bin/main/de/oaa/xxx/gruppe/GruppenbeitragController$CreateBeitragRequest.class index 18d9ea8..b1e2d5c 100644 Binary files a/bin/main/de/oaa/xxx/gruppe/GruppenbeitragController$CreateBeitragRequest.class and b/bin/main/de/oaa/xxx/gruppe/GruppenbeitragController$CreateBeitragRequest.class differ diff --git a/bin/main/de/oaa/xxx/gruppe/GruppenbeitragController$PostsPage.class b/bin/main/de/oaa/xxx/gruppe/GruppenbeitragController$PostsPage.class index 97f995f..a51d49b 100644 Binary files a/bin/main/de/oaa/xxx/gruppe/GruppenbeitragController$PostsPage.class and b/bin/main/de/oaa/xxx/gruppe/GruppenbeitragController$PostsPage.class differ diff --git a/bin/main/de/oaa/xxx/gruppe/GruppenbeitragController$ReportRequest.class b/bin/main/de/oaa/xxx/gruppe/GruppenbeitragController$ReportRequest.class index 03932cb..342011a 100644 Binary files a/bin/main/de/oaa/xxx/gruppe/GruppenbeitragController$ReportRequest.class and b/bin/main/de/oaa/xxx/gruppe/GruppenbeitragController$ReportRequest.class differ diff --git a/bin/main/de/oaa/xxx/gruppe/GruppenbeitragController$VoteRequest.class b/bin/main/de/oaa/xxx/gruppe/GruppenbeitragController$VoteRequest.class index f241976..5f4eb52 100644 Binary files a/bin/main/de/oaa/xxx/gruppe/GruppenbeitragController$VoteRequest.class and b/bin/main/de/oaa/xxx/gruppe/GruppenbeitragController$VoteRequest.class differ diff --git a/bin/main/de/oaa/xxx/gruppe/GruppenbeitragController.class b/bin/main/de/oaa/xxx/gruppe/GruppenbeitragController.class index 6106767..d243124 100644 Binary files a/bin/main/de/oaa/xxx/gruppe/GruppenbeitragController.class and b/bin/main/de/oaa/xxx/gruppe/GruppenbeitragController.class differ diff --git a/bin/main/de/oaa/xxx/hashtag/HashtagController.class b/bin/main/de/oaa/xxx/hashtag/HashtagController.class new file mode 100644 index 0000000..fbe4fb3 Binary files /dev/null and b/bin/main/de/oaa/xxx/hashtag/HashtagController.class differ diff --git a/bin/main/de/oaa/xxx/hashtag/HashtagEntity.class b/bin/main/de/oaa/xxx/hashtag/HashtagEntity.class new file mode 100644 index 0000000..8d32874 Binary files /dev/null and b/bin/main/de/oaa/xxx/hashtag/HashtagEntity.class differ diff --git a/bin/main/de/oaa/xxx/hashtag/HashtagRepository.class b/bin/main/de/oaa/xxx/hashtag/HashtagRepository.class new file mode 100644 index 0000000..578286f Binary files /dev/null and b/bin/main/de/oaa/xxx/hashtag/HashtagRepository.class differ diff --git a/bin/main/de/oaa/xxx/hashtag/HashtagService.class b/bin/main/de/oaa/xxx/hashtag/HashtagService.class new file mode 100644 index 0000000..0dd6f18 Binary files /dev/null and b/bin/main/de/oaa/xxx/hashtag/HashtagService.class differ diff --git a/bin/main/de/oaa/xxx/hashtag/PostHashtagEntity.class b/bin/main/de/oaa/xxx/hashtag/PostHashtagEntity.class new file mode 100644 index 0000000..80968da Binary files /dev/null and b/bin/main/de/oaa/xxx/hashtag/PostHashtagEntity.class differ diff --git a/bin/main/de/oaa/xxx/hashtag/PostHashtagRepository$HashtagFrequency.class b/bin/main/de/oaa/xxx/hashtag/PostHashtagRepository$HashtagFrequency.class new file mode 100644 index 0000000..bbdce78 Binary files /dev/null and b/bin/main/de/oaa/xxx/hashtag/PostHashtagRepository$HashtagFrequency.class differ diff --git a/bin/main/de/oaa/xxx/hashtag/PostHashtagRepository.class b/bin/main/de/oaa/xxx/hashtag/PostHashtagRepository.class new file mode 100644 index 0000000..da1a0e5 Binary files /dev/null and b/bin/main/de/oaa/xxx/hashtag/PostHashtagRepository.class differ diff --git a/bin/main/static/community/feed.html b/bin/main/static/community/feed.html index a7d0de5..efe43d2 100644 --- a/bin/main/static/community/feed.html +++ b/bin/main/static/community/feed.html @@ -68,6 +68,11 @@ .empty-hint { color:var(--color-muted); font-size:0.9rem; margin-top:0.5rem; } .sentinel { height:1px; } + .hashtag-banner { display:flex; align-items:center; gap:0.75rem; margin-bottom:1.25rem; padding:0.65rem 1rem; background:var(--color-card); border:1px solid var(--color-secondary); border-radius:8px; } + .hashtag-banner-tag { font-size:1.05rem; font-weight:700; color:var(--color-primary); } + .hashtag-banner-back { margin-left:auto; font-size:0.82rem; color:var(--color-muted); text-decoration:none; } + .hashtag-banner-back:hover { color:var(--color-primary); } + /* Lightbox */ .lightbox { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.88); z-index:300; align-items:center; justify-content:center; } .lightbox.open { display:flex; } @@ -92,7 +97,13 @@
-
+ + + +
@@ -140,6 +151,13 @@
+ + +
@@ -165,37 +183,68 @@ - + + - + + @@ -157,7 +128,7 @@ function esc(s) { const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; } - function gruppeCard(g, showJoin = false) { + function gruppeCard(g) { const img = g.bild ? `
` : `
👥
`; @@ -165,16 +136,6 @@ ? `${g.myRole === 'ADMIN' ? 'Admin' : 'Mitglied'}` : ''; const privBadge = g.isPrivate ? ' 🔒' : ''; - let actions = ''; - if (showJoin && !g.myRole) { - if (g.myRequestStatus === 'AUSSTEHEND') { - actions = ``; - } else if (g.isPrivate) { - actions = ``; - } else { - actions = ``; - } - } return `
${img} @@ -183,7 +144,6 @@
${g.memberCount} Mitglied${g.memberCount !== 1 ? 'er' : ''} · ${g.postCount} Beiträge
${g.beschreibung ? `
${esc(g.beschreibung.substring(0,80))}${g.beschreibung.length>80?'…':''}
` : ''}
- ${actions ? `
${actions}
` : ''}
`; } @@ -223,37 +183,6 @@ })); } - async function doSearch() { - const q = document.getElementById('searchInput').value.trim(); - if (!q) return; - try { - const res = await fetch('/gruppen/search?q=' + encodeURIComponent(q)); - if (!res.ok) return; - const data = await res.json(); - const grid = document.getElementById('discoverGrid'); - const hint = document.getElementById('discoverHint'); - grid.innerHTML = ''; - if (data.length === 0) { hint.textContent = 'Keine Gruppen gefunden.'; hint.style.display = ''; return; } - hint.style.display = 'none'; - data.forEach(g => grid.insertAdjacentHTML('beforeend', gruppeCard(g, true))); - } catch(e) { console.error(e); } - } - - async function joinGruppe(gruppeId, btn) { - btn.disabled = true; - btn.textContent = '…'; - try { - const res = await fetch('/gruppen/' + gruppeId + '/join', { method: 'POST', headers:{'Content-Type':'application/json'}, body:'{}' }); - if (res.ok || res.status === 201) { - btn.textContent = 'Beigetreten ✓'; - setTimeout(loadMine, 500); - } else { - btn.disabled = false; - btn.textContent = 'Beitreten'; - } - } catch(e) { btn.disabled = false; btn.textContent = 'Beitreten'; } - } - async function loadRequests() { try { const reqRes = await fetch('/gruppen/requests/mine'); @@ -333,42 +262,6 @@ } catch(e) { showCreateError('Fehler: ' + e.message); } } - // ── Join dialog ── - - let pendingJoinGruppeId = null; - function openJoinDialog(gruppeId, name) { - pendingJoinGruppeId = gruppeId; - document.getElementById('joinDialogGroupName').textContent = name; - document.getElementById('joinNachricht').value = ''; - document.getElementById('joinDialog').classList.add('visible'); - } - function closeJoinDialog() { document.getElementById('joinDialog').classList.remove('visible'); pendingJoinGruppeId = null; } - - async function sendJoinRequest() { - if (!pendingJoinGruppeId) return; - document.getElementById('joinError').style.display = 'none'; - const nachricht = document.getElementById('joinNachricht').value.trim() || null; - try { - const res = await fetch('/gruppen/' + pendingJoinGruppeId + '/join', { - method: 'POST', - headers: {'Content-Type':'application/json'}, - body: JSON.stringify({ nachricht }) - }); - if (res.ok || res.status === 201) { - closeJoinDialog(); - doSearch(); - } else { - const el = document.getElementById('joinError'); - el.textContent = 'Fehler beim Senden der Anfrage.'; - el.style.display = 'block'; - } - } catch(e) { - const el = document.getElementById('joinError'); - el.textContent = 'Fehler: ' + e.message; - el.style.display = 'block'; - } - } - // ── Image preview ── function previewBild(input, previewId, dataId) { @@ -401,9 +294,6 @@ document.getElementById('createDialog').addEventListener('click', e => { if (e.target === document.getElementById('createDialog')) closeCreateDialog(); }); - document.getElementById('joinDialog').addEventListener('click', e => { - if (e.target === document.getElementById('joinDialog')) closeJoinDialog(); - }); loadMine(); diff --git a/bin/main/static/community/nachrichten.html b/bin/main/static/community/nachrichten.html index a691a84..eae7222 100644 --- a/bin/main/static/community/nachrichten.html +++ b/bin/main/static/community/nachrichten.html @@ -360,7 +360,7 @@ const list = document.getElementById('convList'); list.innerHTML = ''; if (convs.length === 0) { - list.innerHTML = '
  • Noch keine Nachrichten. Personen suchen
  • '; + list.innerHTML = '
  • Noch keine Nachrichten. Personen suchen
  • '; return; } convs.forEach(c => { diff --git a/bin/main/static/community/personen-suchen.html b/bin/main/static/community/personen-suchen.html deleted file mode 100644 index 1cf76a7..0000000 --- a/bin/main/static/community/personen-suchen.html +++ /dev/null @@ -1,206 +0,0 @@ - - - - - - - Personen suchen – xXx Sphere - - - - - - -
    -
    -

    Personen suchen

    - - - - -

    Gib mindestens 2 Zeichen ein, um zu suchen.

    -
    -
    - - - - - - - diff --git a/bin/main/static/games/common/einladungen.html b/bin/main/static/games/common/einladungen.html new file mode 100644 index 0000000..f198d67 --- /dev/null +++ b/bin/main/static/games/common/einladungen.html @@ -0,0 +1,371 @@ + + + + + + + Einladungen – xXx Sphere + + + + + +
    +
    +

    Einladungen

    + +
    + + +
    + + +
    +
    Wird geladen…
    + + + + + + + + +
    + + + +
    +
    + + + + + + diff --git a/bin/main/static/games/vanilla/neuvanilla.html b/bin/main/static/games/vanilla/neuvanilla.html index 559bed4..27a9699 100644 --- a/bin/main/static/games/vanilla/neuvanilla.html +++ b/bin/main/static/games/vanilla/neuvanilla.html @@ -1213,6 +1213,7 @@ const id = addPlayer(p.name, i === 0, i === 0, false, false); if (id) { restorePlayer(id, p); if (p.userId) userIdToInfo[p.userId] = { playerId: id, name: p.name }; } }); + Object.entries(userIdToInfo).forEach(([userId, info]) => { pruefeChastityConstraint(info.playerId, userId); }); await ladeEinladungenAusDb(userIdToInfo); restoredFromSetup = true; } else { @@ -1273,14 +1274,15 @@ if (draft.gruppenJson) { sessionStorage.setItem('vanilla-session-gruppen', draft.gruppenJson); savedGruppen = new Set(JSON.parse(draft.gruppenJson)); } } } - const selfGeschlecht = user?.geschlecht || null; - const selfWerkzeuge = selfGeschlecht ? (WERKZEUGE_DEFAULTS[selfGeschlecht] || []) : []; + const defaults = await fetch('/user/me/bdsm-defaults').then(r => r.ok ? r.json() : {}).catch(() => ({})); + const selfGeschlecht = defaults.geschlecht || user?.geschlecht || null; + const selfWerkzeuge = defaults.werkzeuge?.length ? defaults.werkzeuge : (selfGeschlecht ? (WERKZEUGE_DEFAULTS[selfGeschlecht] || []) : []); const selfId = addPlayer(user ? user.name : '', true, true, !!selfGeschlecht, false); if (selfId) { if (user?.profilePicture) { playerProfilePics[selfId] = user.profilePicture; updatePlayerHeader(selfId, user.name); } if (playerIds.length < MAX_PLAYERS) addPlayer(); - restorePlayer(selfId, { geschlecht: selfGeschlecht, spieltMit: [], rollen: [], werkzeuge: selfWerkzeuge }); - pruefeChastityConstraint(selfId, myUserId); + restorePlayer(selfId, { geschlecht: selfGeschlecht, spieltMit: defaults.spieltMit || [], rollen: defaults.rollen || [], werkzeuge: selfWerkzeuge }); + if (myUserId) pruefeChastityConstraint(selfId, myUserId); } await ladeEinladungenAusDb(null); } @@ -1324,8 +1326,9 @@ if (user?.profilePicture) { playerProfilePics[guestOwnPlayerId] = user.profilePicture; updatePlayerHeader(guestOwnPlayerId, user?.name || ''); } - restorePlayer(guestOwnPlayerId, { geschlecht: user?.geschlecht || null, spieltMit: [], rollen: [], werkzeuge: [] }); - pruefeChastityConstraint(guestOwnPlayerId, myUserId); + const defaults = await fetch('/user/me/bdsm-defaults').then(r => r.ok ? r.json() : {}).catch(() => ({})); + restorePlayer(guestOwnPlayerId, { geschlecht: defaults.geschlecht || user?.geschlecht || null, spieltMit: defaults.spieltMit || [], rollen: defaults.rollen || [], werkzeuge: defaults.werkzeuge || [] }); + if (myUserId) pruefeChastityConstraint(guestOwnPlayerId, myUserId); document.getElementById('acc-grundeinstellungen-btn').classList.add('is-open'); document.getElementById('acc-grundeinstellungen-body').classList.add('is-open'); diff --git a/bin/main/static/js/hashtag.js b/bin/main/static/js/hashtag.js new file mode 100644 index 0000000..567df05 --- /dev/null +++ b/bin/main/static/js/hashtag.js @@ -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, '"'); + } + + // ── 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( + `#${esc(match[1])}` + ); + 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 }); + }; +})(); diff --git a/bin/main/static/js/mobile-nav.js b/bin/main/static/js/mobile-nav.js index 6fab66a..6e97bd7 100644 --- a/bin/main/static/js/mobile-nav.js +++ b/bin/main/static/js/mobile-nav.js @@ -19,7 +19,7 @@ box-shadow: 0 2px 12px rgba(0,0,0,0.4); z-index: 500; align-items: center; - padding: 0 0.25rem; + padding: 0 4px 0 0.25rem; } .mobile-topbar-logo { position: absolute; @@ -43,9 +43,9 @@ background: none; border: none; color: var(--color-text); - font-size: 1.725rem; + font-size: 1.3rem; line-height: 1; - padding: 0.75rem 0.675rem; + padding: 0.55rem 0.6rem; cursor: pointer; display: flex; align-items: center; diff --git a/bin/main/static/js/nav.js b/bin/main/static/js/nav.js index 70fa159..1a44fb6 100644 --- a/bin/main/static/js/nav.js +++ b/bin/main/static/js/nav.js @@ -301,7 +301,7 @@ '/community/gruppen.html', '/community/gruppe.html', '/community/locations.html', '/community/location-detail.html', '/community/events.html', '/community/event-detail.html', - '/community/abonnements.html', '/community/personen-suchen.html', + '/community/abonnements.html', '/community/benutzer.html', ])} ${column('colDating', 'Dating', col3Html, ['/dating/'])} diff --git a/bin/main/static/js/section-nav.js b/bin/main/static/js/section-nav.js index 4328936..41f1367 100644 --- a/bin/main/static/js/section-nav.js +++ b/bin/main/static/js/section-nav.js @@ -5,12 +5,19 @@ // ── Bereichs-Definitionen ──────────────────────────────────────────────── const SECTIONS = { + common: { + prefixes: ['/games/common/'], + items: [ + { href: '/games/vanilla/neuvanilla.html', icon: 'VANILLA', label: 'Vanilla Game' }, + { href: '/games/bdsm/neubdsm.html', icon: 'BDSM', label: 'BDSM Game' }, + { href: '/games/chastity/neulock.html', icon: 'CHASTITY', label: 'Chastity Game' }, + ], + }, social: { prefixes: ['/community/'], exclude: [ '/community/nachrichten.html', '/community/benachrichtigungen.html', - '/community/einladungen.html', ], items: [ { href: '/community/feed.html', icon: 'FEED', label: 'Feed' }, diff --git a/bin/main/static/js/topbar.js b/bin/main/static/js/topbar.js index 7b88b4b..66d866d 100644 --- a/bin/main/static/js/topbar.js +++ b/bin/main/static/js/topbar.js @@ -168,18 +168,26 @@ async function doSearch(q, overlay) { try { - const res = await fetch('/search?q=' + encodeURIComponent(q) + '&limit=3'); - if (!res.ok) { overlay.innerHTML = '
    Fehler bei der Suche.
    '; return; } - const data = await res.json(); - const { users = [], locations = [], events = [] } = data; + const tagQuery = q.startsWith('#') ? q.slice(1) : q; + const [searchRes, gruppenRes, hashtagRes] = await Promise.all([ + fetch('/search?q=' + encodeURIComponent(q) + '&limit=3'), + fetch('/gruppen/search?q=' + encodeURIComponent(q)), + fetch('/hashtags/suggest?q=' + encodeURIComponent(tagQuery) + '&limit=4') + ]); - if (!users.length && !locations.length && !events.length) { - overlay.innerHTML = '
    Keine Ergebnisse.
    '; - return; - } + const data = searchRes.ok ? await searchRes.json() : {}; + const gruppen = gruppenRes.ok ? await gruppenRes.json() : []; + const hashtags = hashtagRes.ok ? await hashtagRes.json() : []; + + const { users = [], locations = [], events = [] } = data; + const gruppenSlice = gruppen.slice(0, 3); let html = ''; + if (!users.length && !locations.length && !events.length && !gruppenSlice.length && !hashtags.length) { + html += '
    Keine Ergebnisse.
    '; + } + if (users.length) { html += `
    Personen
    `; html += users.map(u => { @@ -213,6 +221,26 @@ }).join(''); } + if (gruppenSlice.length) { + html += `
    Gruppen
    `; + html += gruppenSlice.map(g => { + const av = g.bild + ? `` + : `👥`; + return ` + ${av}${esc(g.name)}`; + }).join(''); + } + + if (hashtags.length) { + html += `
    Hashtags
    `; + html += `
    `; + html += hashtags.map(tag => + `#${esc(tag)}` + ).join(''); + html += `
    `; + } + html += `Alle Ergebnisse anzeigen →`; overlay.innerHTML = html; } catch (e) { diff --git a/bin/main/static/search.html b/bin/main/static/search.html index 5d193a1..5e3c309 100644 --- a/bin/main/static/search.html +++ b/bin/main/static/search.html @@ -163,6 +163,33 @@ transition: border-color 0.15s, color 0.15s; } .search-load-more:hover { border-color: var(--color-primary); color: var(--color-primary); background: none; } + + .hashtag-chip { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.4rem 0.85rem; + background: var(--color-card); + border: 1px solid var(--color-secondary); + border-radius: 20px; + color: var(--color-primary); + font-weight: 600; + font-size: 0.9rem; + text-decoration: none; + transition: border-color 0.15s, background 0.15s; + } + .hashtag-chip:hover { border-color: var(--color-primary); background: var(--color-secondary); } + + /* Dialog (Gruppen-Beitritt) */ + .dialog-backdrop { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.6); z-index:200; align-items:center; justify-content:center; } + .dialog-backdrop.visible { display:flex; } + .dialog { background:var(--color-card); border:1px solid var(--color-secondary); border-radius:12px; padding:1.75rem; width:100%; max-width:420px; box-shadow:0 8px 32px rgba(0,0,0,0.6); max-height:90vh; overflow-y:auto; } + .dialog h3 { color:var(--color-primary); font-size:1.1rem; margin-bottom:1.25rem; } + .dialog label { display:block; font-size:0.8rem; color:#aaa; margin-bottom:0.3rem; margin-top:1rem; } + .dialog textarea { width:100%; padding:0.6rem 0.85rem; border:1px solid var(--color-secondary); border-radius:6px; background:var(--color-secondary); color:var(--color-text); font-size:0.95rem; outline:none; transition:border-color 0.2s; resize:vertical; min-height:80px; box-sizing:border-box; } + .dialog textarea:focus { border-color:var(--color-primary); } + .dialog-actions { display:flex; justify-content:flex-end; gap:0.75rem; margin-top:1.5rem; } + .dialog-actions button { flex:none; margin:0; padding:0.55rem 1.1rem; font-size:0.9rem; width:auto; } @@ -173,7 +200,7 @@
    -
    @@ -188,6 +215,12 @@ + +
    @@ -207,6 +240,31 @@
    + +
    + +
    +
    + +
    + +
    +
    + + + + +
    +
    +

    Beitrittsanfrage senden

    +

    + + + +
    + + +
    @@ -279,10 +337,12 @@ document.getElementById('grid' + cap(t)).innerHTML = ''; document.getElementById('more' + cap(t)).style.display = 'none'; }); - // Alle drei Typen parallel laden + // Alle Typen parallel laden loadChunk('users'); loadChunk('locations'); loadChunk('events'); + loadGruppen(q); + loadHashtags(q); } function clearAll() { @@ -295,6 +355,12 @@ document.getElementById('count' + cap(t)).textContent = '0'; document.getElementById('loading' + cap(t)).style.display = 'none'; }); + document.getElementById('gridGruppen').innerHTML = ''; + document.getElementById('countGruppen').textContent = '0'; + document.getElementById('loadingGruppen').style.display = 'none'; + document.getElementById('gridHashtags').innerHTML = ''; + document.getElementById('countHashtags').textContent = '0'; + document.getElementById('loadingHashtags').style.display = 'none'; const url = new URL(window.location); url.searchParams.delete('q'); history.replaceState(null, '', url); @@ -374,14 +440,185 @@ }); } + // ── Gruppen-Suche ── + + async function loadGruppen(q) { + const loadingEl = document.getElementById('loadingGruppen'); + const grid = document.getElementById('gridGruppen'); + loadingEl.style.display = ''; + grid.innerHTML = ''; + try { + const res = await fetch('/gruppen/search?q=' + encodeURIComponent(q)); + if (!res.ok) throw new Error(); + const data = await res.json(); + document.getElementById('countGruppen').textContent = data.length; + if (!data.length) { + grid.innerHTML = '
    Keine Ergebnisse.
    '; + 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 + ? `
    ` + : `
    👥
    `; + 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}
    ${esc(g.name)}${privBadge}
    ${esc(sub)}
    `; + 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 = '
    Keine Hashtags gefunden.
    '; + return; + } + tags.forEach(tag => { + const a = document.createElement('a'); + a.className = 'hashtag-chip'; + a.href = '/community/feed.html?tag=' + encodeURIComponent(tag); + a.textContent = '#' + tag; + grid.appendChild(a); + }); + } catch (e) { + // ignore + } finally { + loadingEl.style.display = 'none'; + } + } + + async function joinGruppeSearch(gruppeId, btn) { + btn.disabled = true; + btn.textContent = '…'; + try { + const res = await fetch('/gruppen/' + gruppeId + '/join', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{}' + }); + if (res.ok || res.status === 201) { + btn.textContent = 'Beigetreten ✓'; + } else { + btn.disabled = false; + btn.textContent = 'Beitreten'; + } + } catch (e) { + btn.disabled = false; + btn.textContent = 'Beitreten'; + } + } + + // ── Join-Dialog für Gruppen ── + + let searchJoinGruppeId = null; + + function openSearchJoinDialog(gruppeId, name) { + searchJoinGruppeId = gruppeId; + document.getElementById('searchJoinGroupName').textContent = name; + document.getElementById('searchJoinNachricht').value = ''; + document.getElementById('searchJoinError').style.display = 'none'; + document.getElementById('searchJoinDialog').classList.add('visible'); + } + + function closeSearchJoinDialog() { + document.getElementById('searchJoinDialog').classList.remove('visible'); + searchJoinGruppeId = null; + } + + async function sendSearchJoinRequest() { + if (!searchJoinGruppeId) return; + document.getElementById('searchJoinError').style.display = 'none'; + const nachricht = document.getElementById('searchJoinNachricht').value.trim() || null; + try { + const res = await fetch('/gruppen/' + searchJoinGruppeId + '/join', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ nachricht }) + }); + if (res.ok || res.status === 201) { + closeSearchJoinDialog(); + if (currentQuery.length >= 2) loadGruppen(currentQuery); + } else { + const el = document.getElementById('searchJoinError'); + el.textContent = 'Fehler beim Senden der Anfrage.'; + el.style.display = 'block'; + } + } catch (e) { + const el = document.getElementById('searchJoinError'); + el.textContent = 'Fehler: ' + e.message; + el.style.display = 'block'; + } + } + + document.getElementById('searchJoinCancelBtn').addEventListener('click', closeSearchJoinDialog); + document.getElementById('searchJoinSendBtn').addEventListener('click', sendSearchJoinRequest); + document.getElementById('searchJoinDialog').addEventListener('click', e => { + if (e.target === document.getElementById('searchJoinDialog')) closeSearchJoinDialog(); + }); + function cap(s) { return s.charAt(0).toUpperCase() + s.slice(1); } // ── URL-Parameter beim Start auslesen ── - const initQ = new URLSearchParams(window.location.search).get('q') || ''; + const params = new URLSearchParams(window.location.search); + const initQ = params.get('q') || ''; + const initTab = params.get('tab') || ''; + + if (initTab) { + const tabBtn = document.querySelector(`.search-tab-btn[data-tab="${initTab}"]`); + if (tabBtn) tabBtn.click(); + } + if (initQ.length >= 2) { input.value = initQ; - // Warten bis icons.js geladen ist setTimeout(() => startSearch(initQ), 100); + } else if (initTab === 'hashtags') { + // Populäre Tags zeigen wenn kein Suchbegriff + setTimeout(() => loadHashtags(''), 100); } })(); diff --git a/bin/main/static/userhome.html b/bin/main/static/userhome.html index fa94bba..fe813f9 100644 --- a/bin/main/static/userhome.html +++ b/bin/main/static/userhome.html @@ -243,6 +243,33 @@ .lb-comment-compose button { width:auto; margin:0; padding:0.35rem 0.75rem; font-size:0.8rem; } @media (max-width:650px) { .lb-layout { flex-direction:column; height:95vh; } .lb-post-side { border-right:none; border-bottom:1px solid var(--color-secondary); max-height:55vh; } .lb-comments-panel { width:100%; } } + /* ── Spiel starten ── */ + .start-game-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 0.75rem; + } + .start-game-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.6rem; + background: var(--color-card); + border: 1px solid var(--color-secondary); + border-radius: 12px; + padding: 1.25rem 1rem; + text-decoration: none; + color: var(--color-text); + transition: border-color 0.15s, background 0.15s; + text-align: center; + } + .start-game-card:hover { + border-color: var(--color-primary); + background: rgba(var(--color-primary-rgb,233,69,96),0.06); + } + .start-game-icon { font-size: 2rem; line-height: 1; } + .start-game-title { font-size: 0.9rem; font-weight: 600; } + /* ── Neue Mitglieder ── */ .new-members-strip { display: flex; @@ -306,6 +333,25 @@
    + + + `; } @@ -223,37 +183,6 @@ })); } - async function doSearch() { - const q = document.getElementById('searchInput').value.trim(); - if (!q) return; - try { - const res = await fetch('/gruppen/search?q=' + encodeURIComponent(q)); - if (!res.ok) return; - const data = await res.json(); - const grid = document.getElementById('discoverGrid'); - const hint = document.getElementById('discoverHint'); - grid.innerHTML = ''; - if (data.length === 0) { hint.textContent = 'Keine Gruppen gefunden.'; hint.style.display = ''; return; } - hint.style.display = 'none'; - data.forEach(g => grid.insertAdjacentHTML('beforeend', gruppeCard(g, true))); - } catch(e) { console.error(e); } - } - - async function joinGruppe(gruppeId, btn) { - btn.disabled = true; - btn.textContent = '…'; - try { - const res = await fetch('/gruppen/' + gruppeId + '/join', { method: 'POST', headers:{'Content-Type':'application/json'}, body:'{}' }); - if (res.ok || res.status === 201) { - btn.textContent = 'Beigetreten ✓'; - setTimeout(loadMine, 500); - } else { - btn.disabled = false; - btn.textContent = 'Beitreten'; - } - } catch(e) { btn.disabled = false; btn.textContent = 'Beitreten'; } - } - async function loadRequests() { try { const reqRes = await fetch('/gruppen/requests/mine'); @@ -333,42 +262,6 @@ } catch(e) { showCreateError('Fehler: ' + e.message); } } - // ── Join dialog ── - - let pendingJoinGruppeId = null; - function openJoinDialog(gruppeId, name) { - pendingJoinGruppeId = gruppeId; - document.getElementById('joinDialogGroupName').textContent = name; - document.getElementById('joinNachricht').value = ''; - document.getElementById('joinDialog').classList.add('visible'); - } - function closeJoinDialog() { document.getElementById('joinDialog').classList.remove('visible'); pendingJoinGruppeId = null; } - - async function sendJoinRequest() { - if (!pendingJoinGruppeId) return; - document.getElementById('joinError').style.display = 'none'; - const nachricht = document.getElementById('joinNachricht').value.trim() || null; - try { - const res = await fetch('/gruppen/' + pendingJoinGruppeId + '/join', { - method: 'POST', - headers: {'Content-Type':'application/json'}, - body: JSON.stringify({ nachricht }) - }); - if (res.ok || res.status === 201) { - closeJoinDialog(); - doSearch(); - } else { - const el = document.getElementById('joinError'); - el.textContent = 'Fehler beim Senden der Anfrage.'; - el.style.display = 'block'; - } - } catch(e) { - const el = document.getElementById('joinError'); - el.textContent = 'Fehler: ' + e.message; - el.style.display = 'block'; - } - } - // ── Image preview ── function previewBild(input, previewId, dataId) { @@ -401,9 +294,6 @@ document.getElementById('createDialog').addEventListener('click', e => { if (e.target === document.getElementById('createDialog')) closeCreateDialog(); }); - document.getElementById('joinDialog').addEventListener('click', e => { - if (e.target === document.getElementById('joinDialog')) closeJoinDialog(); - }); loadMine(); diff --git a/src/main/resources/static/community/nachrichten.html b/src/main/resources/static/community/nachrichten.html index a691a84..eae7222 100644 --- a/src/main/resources/static/community/nachrichten.html +++ b/src/main/resources/static/community/nachrichten.html @@ -360,7 +360,7 @@ const list = document.getElementById('convList'); list.innerHTML = ''; if (convs.length === 0) { - list.innerHTML = '
  • Noch keine Nachrichten. Personen suchen
  • '; + list.innerHTML = '
  • Noch keine Nachrichten. Personen suchen
  • '; return; } convs.forEach(c => { diff --git a/src/main/resources/static/community/personen-suchen.html b/src/main/resources/static/community/personen-suchen.html deleted file mode 100644 index 1cf76a7..0000000 --- a/src/main/resources/static/community/personen-suchen.html +++ /dev/null @@ -1,206 +0,0 @@ - - - - - - - Personen suchen – xXx Sphere - - - - - - -
    -
    -

    Personen suchen

    - - - - -

    Gib mindestens 2 Zeichen ein, um zu suchen.

    -
    -
    - - - - - - - diff --git a/src/main/resources/static/games/common/einladungen.html b/src/main/resources/static/games/common/einladungen.html new file mode 100644 index 0000000..f198d67 --- /dev/null +++ b/src/main/resources/static/games/common/einladungen.html @@ -0,0 +1,371 @@ + + + + + + + Einladungen – xXx Sphere + + + + + +
    +
    +

    Einladungen

    + +
    + + +
    + + +
    +
    Wird geladen…
    + + + + + + + + +
    + + + +
    +
    + + + + + + diff --git a/src/main/resources/static/games/vanilla/neuvanilla.html b/src/main/resources/static/games/vanilla/neuvanilla.html index 559bed4..27a9699 100644 --- a/src/main/resources/static/games/vanilla/neuvanilla.html +++ b/src/main/resources/static/games/vanilla/neuvanilla.html @@ -1213,6 +1213,7 @@ const id = addPlayer(p.name, i === 0, i === 0, false, false); if (id) { restorePlayer(id, p); if (p.userId) userIdToInfo[p.userId] = { playerId: id, name: p.name }; } }); + Object.entries(userIdToInfo).forEach(([userId, info]) => { pruefeChastityConstraint(info.playerId, userId); }); await ladeEinladungenAusDb(userIdToInfo); restoredFromSetup = true; } else { @@ -1273,14 +1274,15 @@ if (draft.gruppenJson) { sessionStorage.setItem('vanilla-session-gruppen', draft.gruppenJson); savedGruppen = new Set(JSON.parse(draft.gruppenJson)); } } } - const selfGeschlecht = user?.geschlecht || null; - const selfWerkzeuge = selfGeschlecht ? (WERKZEUGE_DEFAULTS[selfGeschlecht] || []) : []; + const defaults = await fetch('/user/me/bdsm-defaults').then(r => r.ok ? r.json() : {}).catch(() => ({})); + const selfGeschlecht = defaults.geschlecht || user?.geschlecht || null; + const selfWerkzeuge = defaults.werkzeuge?.length ? defaults.werkzeuge : (selfGeschlecht ? (WERKZEUGE_DEFAULTS[selfGeschlecht] || []) : []); const selfId = addPlayer(user ? user.name : '', true, true, !!selfGeschlecht, false); if (selfId) { if (user?.profilePicture) { playerProfilePics[selfId] = user.profilePicture; updatePlayerHeader(selfId, user.name); } if (playerIds.length < MAX_PLAYERS) addPlayer(); - restorePlayer(selfId, { geschlecht: selfGeschlecht, spieltMit: [], rollen: [], werkzeuge: selfWerkzeuge }); - pruefeChastityConstraint(selfId, myUserId); + restorePlayer(selfId, { geschlecht: selfGeschlecht, spieltMit: defaults.spieltMit || [], rollen: defaults.rollen || [], werkzeuge: selfWerkzeuge }); + if (myUserId) pruefeChastityConstraint(selfId, myUserId); } await ladeEinladungenAusDb(null); } @@ -1324,8 +1326,9 @@ if (user?.profilePicture) { playerProfilePics[guestOwnPlayerId] = user.profilePicture; updatePlayerHeader(guestOwnPlayerId, user?.name || ''); } - restorePlayer(guestOwnPlayerId, { geschlecht: user?.geschlecht || null, spieltMit: [], rollen: [], werkzeuge: [] }); - pruefeChastityConstraint(guestOwnPlayerId, myUserId); + const defaults = await fetch('/user/me/bdsm-defaults').then(r => r.ok ? r.json() : {}).catch(() => ({})); + restorePlayer(guestOwnPlayerId, { geschlecht: defaults.geschlecht || user?.geschlecht || null, spieltMit: defaults.spieltMit || [], rollen: defaults.rollen || [], werkzeuge: defaults.werkzeuge || [] }); + if (myUserId) pruefeChastityConstraint(guestOwnPlayerId, myUserId); document.getElementById('acc-grundeinstellungen-btn').classList.add('is-open'); document.getElementById('acc-grundeinstellungen-body').classList.add('is-open'); diff --git a/src/main/resources/static/js/hashtag.js b/src/main/resources/static/js/hashtag.js new file mode 100644 index 0000000..567df05 --- /dev/null +++ b/src/main/resources/static/js/hashtag.js @@ -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, '"'); + } + + // ── 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( + `#${esc(match[1])}` + ); + 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 }); + }; +})(); diff --git a/src/main/resources/static/js/mobile-nav.js b/src/main/resources/static/js/mobile-nav.js index 6fab66a..6e97bd7 100644 --- a/src/main/resources/static/js/mobile-nav.js +++ b/src/main/resources/static/js/mobile-nav.js @@ -19,7 +19,7 @@ box-shadow: 0 2px 12px rgba(0,0,0,0.4); z-index: 500; align-items: center; - padding: 0 0.25rem; + padding: 0 4px 0 0.25rem; } .mobile-topbar-logo { position: absolute; @@ -43,9 +43,9 @@ background: none; border: none; color: var(--color-text); - font-size: 1.725rem; + font-size: 1.3rem; line-height: 1; - padding: 0.75rem 0.675rem; + padding: 0.55rem 0.6rem; cursor: pointer; display: flex; align-items: center; diff --git a/src/main/resources/static/js/nav.js b/src/main/resources/static/js/nav.js index 70fa159..1a44fb6 100644 --- a/src/main/resources/static/js/nav.js +++ b/src/main/resources/static/js/nav.js @@ -301,7 +301,7 @@ '/community/gruppen.html', '/community/gruppe.html', '/community/locations.html', '/community/location-detail.html', '/community/events.html', '/community/event-detail.html', - '/community/abonnements.html', '/community/personen-suchen.html', + '/community/abonnements.html', '/community/benutzer.html', ])} ${column('colDating', 'Dating', col3Html, ['/dating/'])} diff --git a/src/main/resources/static/js/section-nav.js b/src/main/resources/static/js/section-nav.js index 4328936..41f1367 100644 --- a/src/main/resources/static/js/section-nav.js +++ b/src/main/resources/static/js/section-nav.js @@ -5,12 +5,19 @@ // ── Bereichs-Definitionen ──────────────────────────────────────────────── const SECTIONS = { + common: { + prefixes: ['/games/common/'], + items: [ + { href: '/games/vanilla/neuvanilla.html', icon: 'VANILLA', label: 'Vanilla Game' }, + { href: '/games/bdsm/neubdsm.html', icon: 'BDSM', label: 'BDSM Game' }, + { href: '/games/chastity/neulock.html', icon: 'CHASTITY', label: 'Chastity Game' }, + ], + }, social: { prefixes: ['/community/'], exclude: [ '/community/nachrichten.html', '/community/benachrichtigungen.html', - '/community/einladungen.html', ], items: [ { href: '/community/feed.html', icon: 'FEED', label: 'Feed' }, diff --git a/src/main/resources/static/js/topbar.js b/src/main/resources/static/js/topbar.js index 7b88b4b..66d866d 100644 --- a/src/main/resources/static/js/topbar.js +++ b/src/main/resources/static/js/topbar.js @@ -168,18 +168,26 @@ async function doSearch(q, overlay) { try { - const res = await fetch('/search?q=' + encodeURIComponent(q) + '&limit=3'); - if (!res.ok) { overlay.innerHTML = '
    Fehler bei der Suche.
    '; return; } - const data = await res.json(); - const { users = [], locations = [], events = [] } = data; + const tagQuery = q.startsWith('#') ? q.slice(1) : q; + const [searchRes, gruppenRes, hashtagRes] = await Promise.all([ + fetch('/search?q=' + encodeURIComponent(q) + '&limit=3'), + fetch('/gruppen/search?q=' + encodeURIComponent(q)), + fetch('/hashtags/suggest?q=' + encodeURIComponent(tagQuery) + '&limit=4') + ]); - if (!users.length && !locations.length && !events.length) { - overlay.innerHTML = '
    Keine Ergebnisse.
    '; - return; - } + const data = searchRes.ok ? await searchRes.json() : {}; + const gruppen = gruppenRes.ok ? await gruppenRes.json() : []; + const hashtags = hashtagRes.ok ? await hashtagRes.json() : []; + + const { users = [], locations = [], events = [] } = data; + const gruppenSlice = gruppen.slice(0, 3); let html = ''; + if (!users.length && !locations.length && !events.length && !gruppenSlice.length && !hashtags.length) { + html += '
    Keine Ergebnisse.
    '; + } + if (users.length) { html += `
    Personen
    `; html += users.map(u => { @@ -213,6 +221,26 @@ }).join(''); } + if (gruppenSlice.length) { + html += `
    Gruppen
    `; + html += gruppenSlice.map(g => { + const av = g.bild + ? `` + : `👥`; + return ` + ${av}${esc(g.name)}`; + }).join(''); + } + + if (hashtags.length) { + html += `
    Hashtags
    `; + html += `
    `; + html += hashtags.map(tag => + `#${esc(tag)}` + ).join(''); + html += `
    `; + } + html += `Alle Ergebnisse anzeigen →`; overlay.innerHTML = html; } catch (e) { diff --git a/src/main/resources/static/search.html b/src/main/resources/static/search.html index 5d193a1..5e3c309 100644 --- a/src/main/resources/static/search.html +++ b/src/main/resources/static/search.html @@ -163,6 +163,33 @@ transition: border-color 0.15s, color 0.15s; } .search-load-more:hover { border-color: var(--color-primary); color: var(--color-primary); background: none; } + + .hashtag-chip { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.4rem 0.85rem; + background: var(--color-card); + border: 1px solid var(--color-secondary); + border-radius: 20px; + color: var(--color-primary); + font-weight: 600; + font-size: 0.9rem; + text-decoration: none; + transition: border-color 0.15s, background 0.15s; + } + .hashtag-chip:hover { border-color: var(--color-primary); background: var(--color-secondary); } + + /* Dialog (Gruppen-Beitritt) */ + .dialog-backdrop { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.6); z-index:200; align-items:center; justify-content:center; } + .dialog-backdrop.visible { display:flex; } + .dialog { background:var(--color-card); border:1px solid var(--color-secondary); border-radius:12px; padding:1.75rem; width:100%; max-width:420px; box-shadow:0 8px 32px rgba(0,0,0,0.6); max-height:90vh; overflow-y:auto; } + .dialog h3 { color:var(--color-primary); font-size:1.1rem; margin-bottom:1.25rem; } + .dialog label { display:block; font-size:0.8rem; color:#aaa; margin-bottom:0.3rem; margin-top:1rem; } + .dialog textarea { width:100%; padding:0.6rem 0.85rem; border:1px solid var(--color-secondary); border-radius:6px; background:var(--color-secondary); color:var(--color-text); font-size:0.95rem; outline:none; transition:border-color 0.2s; resize:vertical; min-height:80px; box-sizing:border-box; } + .dialog textarea:focus { border-color:var(--color-primary); } + .dialog-actions { display:flex; justify-content:flex-end; gap:0.75rem; margin-top:1.5rem; } + .dialog-actions button { flex:none; margin:0; padding:0.55rem 1.1rem; font-size:0.9rem; width:auto; } @@ -173,7 +200,7 @@
    -
    @@ -188,6 +215,12 @@ + +
    @@ -207,6 +240,31 @@
    + +
    + +
    +
    + +
    + +
    +
    + + + + +
    +
    +

    Beitrittsanfrage senden

    +

    + + + +
    + + +
    @@ -279,10 +337,12 @@ document.getElementById('grid' + cap(t)).innerHTML = ''; document.getElementById('more' + cap(t)).style.display = 'none'; }); - // Alle drei Typen parallel laden + // Alle Typen parallel laden loadChunk('users'); loadChunk('locations'); loadChunk('events'); + loadGruppen(q); + loadHashtags(q); } function clearAll() { @@ -295,6 +355,12 @@ document.getElementById('count' + cap(t)).textContent = '0'; document.getElementById('loading' + cap(t)).style.display = 'none'; }); + document.getElementById('gridGruppen').innerHTML = ''; + document.getElementById('countGruppen').textContent = '0'; + document.getElementById('loadingGruppen').style.display = 'none'; + document.getElementById('gridHashtags').innerHTML = ''; + document.getElementById('countHashtags').textContent = '0'; + document.getElementById('loadingHashtags').style.display = 'none'; const url = new URL(window.location); url.searchParams.delete('q'); history.replaceState(null, '', url); @@ -374,14 +440,185 @@ }); } + // ── Gruppen-Suche ── + + async function loadGruppen(q) { + const loadingEl = document.getElementById('loadingGruppen'); + const grid = document.getElementById('gridGruppen'); + loadingEl.style.display = ''; + grid.innerHTML = ''; + try { + const res = await fetch('/gruppen/search?q=' + encodeURIComponent(q)); + if (!res.ok) throw new Error(); + const data = await res.json(); + document.getElementById('countGruppen').textContent = data.length; + if (!data.length) { + grid.innerHTML = '
    Keine Ergebnisse.
    '; + 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 + ? `
    ` + : `
    👥
    `; + 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}
    ${esc(g.name)}${privBadge}
    ${esc(sub)}
    `; + 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 = '
    Keine Hashtags gefunden.
    '; + return; + } + tags.forEach(tag => { + const a = document.createElement('a'); + a.className = 'hashtag-chip'; + a.href = '/community/feed.html?tag=' + encodeURIComponent(tag); + a.textContent = '#' + tag; + grid.appendChild(a); + }); + } catch (e) { + // ignore + } finally { + loadingEl.style.display = 'none'; + } + } + + async function joinGruppeSearch(gruppeId, btn) { + btn.disabled = true; + btn.textContent = '…'; + try { + const res = await fetch('/gruppen/' + gruppeId + '/join', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{}' + }); + if (res.ok || res.status === 201) { + btn.textContent = 'Beigetreten ✓'; + } else { + btn.disabled = false; + btn.textContent = 'Beitreten'; + } + } catch (e) { + btn.disabled = false; + btn.textContent = 'Beitreten'; + } + } + + // ── Join-Dialog für Gruppen ── + + let searchJoinGruppeId = null; + + function openSearchJoinDialog(gruppeId, name) { + searchJoinGruppeId = gruppeId; + document.getElementById('searchJoinGroupName').textContent = name; + document.getElementById('searchJoinNachricht').value = ''; + document.getElementById('searchJoinError').style.display = 'none'; + document.getElementById('searchJoinDialog').classList.add('visible'); + } + + function closeSearchJoinDialog() { + document.getElementById('searchJoinDialog').classList.remove('visible'); + searchJoinGruppeId = null; + } + + async function sendSearchJoinRequest() { + if (!searchJoinGruppeId) return; + document.getElementById('searchJoinError').style.display = 'none'; + const nachricht = document.getElementById('searchJoinNachricht').value.trim() || null; + try { + const res = await fetch('/gruppen/' + searchJoinGruppeId + '/join', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ nachricht }) + }); + if (res.ok || res.status === 201) { + closeSearchJoinDialog(); + if (currentQuery.length >= 2) loadGruppen(currentQuery); + } else { + const el = document.getElementById('searchJoinError'); + el.textContent = 'Fehler beim Senden der Anfrage.'; + el.style.display = 'block'; + } + } catch (e) { + const el = document.getElementById('searchJoinError'); + el.textContent = 'Fehler: ' + e.message; + el.style.display = 'block'; + } + } + + document.getElementById('searchJoinCancelBtn').addEventListener('click', closeSearchJoinDialog); + document.getElementById('searchJoinSendBtn').addEventListener('click', sendSearchJoinRequest); + document.getElementById('searchJoinDialog').addEventListener('click', e => { + if (e.target === document.getElementById('searchJoinDialog')) closeSearchJoinDialog(); + }); + function cap(s) { return s.charAt(0).toUpperCase() + s.slice(1); } // ── URL-Parameter beim Start auslesen ── - const initQ = new URLSearchParams(window.location.search).get('q') || ''; + const params = new URLSearchParams(window.location.search); + const initQ = params.get('q') || ''; + const initTab = params.get('tab') || ''; + + if (initTab) { + const tabBtn = document.querySelector(`.search-tab-btn[data-tab="${initTab}"]`); + if (tabBtn) tabBtn.click(); + } + if (initQ.length >= 2) { input.value = initQ; - // Warten bis icons.js geladen ist setTimeout(() => startSearch(initQ), 100); + } else if (initTab === 'hashtags') { + // Populäre Tags zeigen wenn kein Suchbegriff + setTimeout(() => loadHashtags(''), 100); } })(); diff --git a/src/main/resources/static/userhome.html b/src/main/resources/static/userhome.html index fa94bba..fe813f9 100644 --- a/src/main/resources/static/userhome.html +++ b/src/main/resources/static/userhome.html @@ -243,6 +243,33 @@ .lb-comment-compose button { width:auto; margin:0; padding:0.35rem 0.75rem; font-size:0.8rem; } @media (max-width:650px) { .lb-layout { flex-direction:column; height:95vh; } .lb-post-side { border-right:none; border-bottom:1px solid var(--color-secondary); max-height:55vh; } .lb-comments-panel { width:100%; } } + /* ── Spiel starten ── */ + .start-game-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 0.75rem; + } + .start-game-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.6rem; + background: var(--color-card); + border: 1px solid var(--color-secondary); + border-radius: 12px; + padding: 1.25rem 1rem; + text-decoration: none; + color: var(--color-text); + transition: border-color 0.15s, background 0.15s; + text-align: center; + } + .start-game-card:hover { + border-color: var(--color-primary); + background: rgba(var(--color-primary-rgb,233,69,96),0.06); + } + .start-game-icon { font-size: 2rem; line-height: 1; } + .start-game-title { font-size: 0.9rem; font-weight: 600; } + /* ── Neue Mitglieder ── */ .new-members-strip { display: flex; @@ -306,6 +333,25 @@
    + + +