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

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

View File

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

View File

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

View File

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

View File

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

View File

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