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:
217
bin/main/static/js/hashtag.js
Normal file
217
bin/main/static/js/hashtag.js
Normal file
@@ -0,0 +1,217 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// ── Styles ──────────────────────────────────────────────────────────────
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.post-hashtag {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
.post-hashtag:hover { text-decoration: underline; }
|
||||
|
||||
.hashtag-dropdown {
|
||||
position: fixed;
|
||||
z-index: 600;
|
||||
background: var(--color-card);
|
||||
border: 1px solid var(--color-secondary);
|
||||
border-radius: 8px;
|
||||
min-width: 170px;
|
||||
max-width: 260px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.45);
|
||||
overflow: hidden;
|
||||
}
|
||||
.hashtag-dropdown-item {
|
||||
padding: 0.45rem 0.9rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.88rem;
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.hashtag-dropdown-item:hover,
|
||||
.hashtag-dropdown-item.active {
|
||||
background: var(--color-secondary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.hashtag-dropdown-create {
|
||||
font-style: italic;
|
||||
border-top: 1px solid var(--color-secondary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.hashtag-dropdown-hint {
|
||||
padding: 0.45rem 0.9rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text);
|
||||
opacity: 0.6;
|
||||
font-style: italic;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
// ── Escape helper (works even if shared.js not yet loaded) ───────────────
|
||||
function esc(s) {
|
||||
return String(s ?? '')
|
||||
.replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ── renderTextWithHashtags ────────────────────────────────────────────────
|
||||
window.renderTextWithHashtags = function (text) {
|
||||
if (!text) return '';
|
||||
const PATTERN = /#([\wäöüÄÖÜß]{1,100})/g;
|
||||
const parts = [];
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
while ((match = PATTERN.exec(text)) !== null) {
|
||||
parts.push(esc(text.slice(lastIndex, match.index)));
|
||||
const tag = match[1].toLowerCase();
|
||||
parts.push(
|
||||
`<a href="/community/feed.html?tag=${encodeURIComponent(tag)}" ` +
|
||||
`class="post-hashtag" onclick="event.stopPropagation()">#${esc(match[1])}</a>`
|
||||
);
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
parts.push(esc(text.slice(lastIndex)));
|
||||
return parts.join('');
|
||||
};
|
||||
|
||||
// ── attachHashtagAutocomplete ─────────────────────────────────────────────
|
||||
window.attachHashtagAutocomplete = function (textarea) {
|
||||
let dropdownEl = null;
|
||||
let suggestions = [];
|
||||
let selectedIdx = -1;
|
||||
let debounce = null;
|
||||
|
||||
// Returns { prefix, hashStart } if cursor is inside a #word, else null
|
||||
function getHashAtCursor() {
|
||||
const pos = textarea.selectionStart;
|
||||
const text = textarea.value;
|
||||
let i = pos - 1;
|
||||
while (i >= 0 && /[\wäöüÄÖÜß]/.test(text[i])) i--;
|
||||
if (i >= 0 && text[i] === '#') {
|
||||
return { prefix: text.slice(i + 1, pos), hashStart: i };
|
||||
}
|
||||
if (i === -1 && text.length > 0 && text[0] === '#' && pos > 0) {
|
||||
return { prefix: text.slice(1, pos), hashStart: 0 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function positionDropdown() {
|
||||
if (!dropdownEl) return;
|
||||
const r = textarea.getBoundingClientRect();
|
||||
dropdownEl.style.top = (r.bottom + window.scrollY + 2) + 'px';
|
||||
dropdownEl.style.left = (r.left + window.scrollX) + 'px';
|
||||
}
|
||||
|
||||
function highlight() {
|
||||
if (!dropdownEl) return;
|
||||
dropdownEl.querySelectorAll('.hashtag-dropdown-item').forEach((el, i) => {
|
||||
el.classList.toggle('active', i === selectedIdx);
|
||||
});
|
||||
}
|
||||
|
||||
function removeDropdown() {
|
||||
dropdownEl?.remove();
|
||||
dropdownEl = null;
|
||||
suggestions = [];
|
||||
selectedIdx = -1;
|
||||
}
|
||||
|
||||
function showDropdown(items, prefix) {
|
||||
removeDropdown();
|
||||
const lPrefix = prefix ? prefix.toLowerCase() : '';
|
||||
const hasCreate = lPrefix.length > 0 && !items.some(t => t === lPrefix);
|
||||
|
||||
if (!items.length && !hasCreate) {
|
||||
if (prefix !== undefined && prefix.length === 0) {
|
||||
// Nur "#" getippt, noch keine populären Tags
|
||||
dropdownEl = document.createElement('div');
|
||||
dropdownEl.className = 'hashtag-dropdown';
|
||||
const hint = document.createElement('div');
|
||||
hint.className = 'hashtag-dropdown-hint';
|
||||
hint.textContent = 'Tippe weiter um einen Hashtag zu erstellen';
|
||||
dropdownEl.appendChild(hint);
|
||||
document.body.appendChild(dropdownEl);
|
||||
positionDropdown();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
suggestions = hasCreate ? [...items, lPrefix] : [...items];
|
||||
|
||||
dropdownEl = document.createElement('div');
|
||||
dropdownEl.className = 'hashtag-dropdown';
|
||||
|
||||
suggestions.forEach((tag, i) => {
|
||||
const isCreate = hasCreate && i === suggestions.length - 1;
|
||||
const item = document.createElement('div');
|
||||
item.className = 'hashtag-dropdown-item' + (isCreate ? ' hashtag-dropdown-create' : '');
|
||||
item.textContent = (isCreate ? '+ #' : '#') + tag;
|
||||
item.addEventListener('mousedown', e => { e.preventDefault(); insertTag(tag); });
|
||||
item.addEventListener('mouseover', () => { selectedIdx = i; highlight(); });
|
||||
dropdownEl.appendChild(item);
|
||||
});
|
||||
|
||||
document.body.appendChild(dropdownEl);
|
||||
positionDropdown();
|
||||
}
|
||||
|
||||
function insertTag(tag) {
|
||||
const info = getHashAtCursor();
|
||||
if (!info) { removeDropdown(); return; }
|
||||
const v = textarea.value;
|
||||
const cursor = textarea.selectionStart;
|
||||
const before = v.slice(0, info.hashStart);
|
||||
const after = v.slice(cursor);
|
||||
const inserted = '#' + tag + ' ';
|
||||
textarea.value = before + inserted + after;
|
||||
const newPos = before.length + inserted.length;
|
||||
textarea.setSelectionRange(newPos, newPos);
|
||||
textarea.focus();
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
removeDropdown();
|
||||
}
|
||||
|
||||
textarea.addEventListener('input', () => {
|
||||
clearTimeout(debounce);
|
||||
const info = getHashAtCursor();
|
||||
if (!info) { removeDropdown(); return; }
|
||||
debounce = setTimeout(async () => {
|
||||
try {
|
||||
const url = info.prefix.length === 0
|
||||
? '/hashtags/popular?limit=6'
|
||||
: '/hashtags/suggest?q=' + encodeURIComponent(info.prefix) + '&limit=6';
|
||||
const res = await fetch(url);
|
||||
if (res.ok) showDropdown(await res.json(), info.prefix);
|
||||
else showDropdown([], info.prefix);
|
||||
} catch { removeDropdown(); }
|
||||
}, 150);
|
||||
});
|
||||
|
||||
textarea.addEventListener('keydown', e => {
|
||||
if (!dropdownEl) return;
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
selectedIdx = Math.min(selectedIdx + 1, suggestions.length - 1);
|
||||
highlight();
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
selectedIdx = Math.max(selectedIdx - 1, 0);
|
||||
highlight();
|
||||
} else if ((e.key === 'Enter' || e.key === 'Tab') && selectedIdx >= 0) {
|
||||
e.preventDefault();
|
||||
insertTag(suggestions[selectedIdx]);
|
||||
} else if (e.key === 'Escape') {
|
||||
removeDropdown();
|
||||
}
|
||||
});
|
||||
|
||||
textarea.addEventListener('blur', () => setTimeout(removeDropdown, 150));
|
||||
window.addEventListener('scroll', positionDropdown, { passive: true });
|
||||
window.addEventListener('resize', positionDropdown, { passive: true });
|
||||
};
|
||||
})();
|
||||
@@ -19,7 +19,7 @@
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.4);
|
||||
z-index: 500;
|
||||
align-items: center;
|
||||
padding: 0 0.25rem;
|
||||
padding: 0 4px 0 0.25rem;
|
||||
}
|
||||
.mobile-topbar-logo {
|
||||
position: absolute;
|
||||
@@ -43,9 +43,9 @@
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text);
|
||||
font-size: 1.725rem;
|
||||
font-size: 1.3rem;
|
||||
line-height: 1;
|
||||
padding: 0.75rem 0.675rem;
|
||||
padding: 0.55rem 0.6rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -301,7 +301,7 @@
|
||||
'/community/gruppen.html', '/community/gruppe.html',
|
||||
'/community/locations.html', '/community/location-detail.html',
|
||||
'/community/events.html', '/community/event-detail.html',
|
||||
'/community/abonnements.html', '/community/personen-suchen.html',
|
||||
'/community/abonnements.html',
|
||||
'/community/benutzer.html',
|
||||
])}
|
||||
${column('colDating', 'Dating', col3Html, ['/dating/'])}
|
||||
|
||||
@@ -5,12 +5,19 @@
|
||||
|
||||
// ── Bereichs-Definitionen ────────────────────────────────────────────────
|
||||
const SECTIONS = {
|
||||
common: {
|
||||
prefixes: ['/games/common/'],
|
||||
items: [
|
||||
{ href: '/games/vanilla/neuvanilla.html', icon: 'VANILLA', label: 'Vanilla Game' },
|
||||
{ href: '/games/bdsm/neubdsm.html', icon: 'BDSM', label: 'BDSM Game' },
|
||||
{ href: '/games/chastity/neulock.html', icon: 'CHASTITY', label: 'Chastity Game' },
|
||||
],
|
||||
},
|
||||
social: {
|
||||
prefixes: ['/community/'],
|
||||
exclude: [
|
||||
'/community/nachrichten.html',
|
||||
'/community/benachrichtigungen.html',
|
||||
'/community/einladungen.html',
|
||||
],
|
||||
items: [
|
||||
{ href: '/community/feed.html', icon: 'FEED', label: 'Feed' },
|
||||
|
||||
@@ -168,18 +168,26 @@
|
||||
|
||||
async function doSearch(q, overlay) {
|
||||
try {
|
||||
const res = await fetch('/search?q=' + encodeURIComponent(q) + '&limit=3');
|
||||
if (!res.ok) { overlay.innerHTML = '<div class="topbar-search-hint">Fehler bei der Suche.</div>'; return; }
|
||||
const data = await res.json();
|
||||
const { users = [], locations = [], events = [] } = data;
|
||||
const tagQuery = q.startsWith('#') ? q.slice(1) : q;
|
||||
const [searchRes, gruppenRes, hashtagRes] = await Promise.all([
|
||||
fetch('/search?q=' + encodeURIComponent(q) + '&limit=3'),
|
||||
fetch('/gruppen/search?q=' + encodeURIComponent(q)),
|
||||
fetch('/hashtags/suggest?q=' + encodeURIComponent(tagQuery) + '&limit=4')
|
||||
]);
|
||||
|
||||
if (!users.length && !locations.length && !events.length) {
|
||||
overlay.innerHTML = '<div class="topbar-search-hint">Keine Ergebnisse.</div>';
|
||||
return;
|
||||
}
|
||||
const data = searchRes.ok ? await searchRes.json() : {};
|
||||
const gruppen = gruppenRes.ok ? await gruppenRes.json() : [];
|
||||
const hashtags = hashtagRes.ok ? await hashtagRes.json() : [];
|
||||
|
||||
const { users = [], locations = [], events = [] } = data;
|
||||
const gruppenSlice = gruppen.slice(0, 3);
|
||||
|
||||
let html = '';
|
||||
|
||||
if (!users.length && !locations.length && !events.length && !gruppenSlice.length && !hashtags.length) {
|
||||
html += '<div class="topbar-search-hint">Keine Ergebnisse.</div>';
|
||||
}
|
||||
|
||||
if (users.length) {
|
||||
html += `<div class="topbar-search-section">Personen</div>`;
|
||||
html += users.map(u => {
|
||||
@@ -213,6 +221,26 @@
|
||||
}).join('');
|
||||
}
|
||||
|
||||
if (gruppenSlice.length) {
|
||||
html += `<div class="topbar-search-section">Gruppen</div>`;
|
||||
html += gruppenSlice.map(g => {
|
||||
const av = g.bild
|
||||
? `<img src="data:image/jpeg;base64,${esc(g.bild)}" class="topbar-search-avatar" alt="">`
|
||||
: `<span class="topbar-search-avatar topbar-search-avatar--placeholder">👥</span>`;
|
||||
return `<a href="/community/gruppe.html?gruppeId=${esc(g.gruppeId)}" class="topbar-search-result">
|
||||
${av}<span>${esc(g.name)}</span></a>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
if (hashtags.length) {
|
||||
html += `<div class="topbar-search-section">Hashtags</div>`;
|
||||
html += `<div style="display:flex;flex-wrap:wrap;gap:0.4rem;padding:0.4rem 0.75rem;">`;
|
||||
html += hashtags.map(tag =>
|
||||
`<a href="/community/feed.html?tag=${encodeURIComponent(tag)}" style="display:inline-block;padding:0.25rem 0.65rem;background:var(--color-secondary);border-radius:14px;font-size:0.82rem;font-weight:600;color:var(--color-primary);text-decoration:none;">#${esc(tag)}</a>`
|
||||
).join('');
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
html += `<a href="/search.html?q=${encodeURIComponent(q)}" class="topbar-search-all">Alle Ergebnisse anzeigen →</a>`;
|
||||
overlay.innerHTML = html;
|
||||
} catch (e) {
|
||||
|
||||
Reference in New Issue
Block a user