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

@@ -163,6 +163,33 @@
transition: border-color 0.15s, color 0.15s;
}
.search-load-more:hover { border-color: var(--color-primary); color: var(--color-primary); background: none; }
.hashtag-chip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.4rem 0.85rem;
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 20px;
color: var(--color-primary);
font-weight: 600;
font-size: 0.9rem;
text-decoration: none;
transition: border-color 0.15s, background 0.15s;
}
.hashtag-chip:hover { border-color: var(--color-primary); background: var(--color-secondary); }
/* Dialog (Gruppen-Beitritt) */
.dialog-backdrop { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.6); z-index:200; align-items:center; justify-content:center; }
.dialog-backdrop.visible { display:flex; }
.dialog { background:var(--color-card); border:1px solid var(--color-secondary); border-radius:12px; padding:1.75rem; width:100%; max-width:420px; box-shadow:0 8px 32px rgba(0,0,0,0.6); max-height:90vh; overflow-y:auto; }
.dialog h3 { color:var(--color-primary); font-size:1.1rem; margin-bottom:1.25rem; }
.dialog label { display:block; font-size:0.8rem; color:#aaa; margin-bottom:0.3rem; margin-top:1rem; }
.dialog textarea { width:100%; padding:0.6rem 0.85rem; border:1px solid var(--color-secondary); border-radius:6px; background:var(--color-secondary); color:var(--color-text); font-size:0.95rem; outline:none; transition:border-color 0.2s; resize:vertical; min-height:80px; box-sizing:border-box; }
.dialog textarea:focus { border-color:var(--color-primary); }
.dialog-actions { display:flex; justify-content:flex-end; gap:0.75rem; margin-top:1.5rem; }
.dialog-actions button { flex:none; margin:0; padding:0.55rem 1.1rem; font-size:0.9rem; width:auto; }
</style>
</head>
<body class="app">
@@ -173,7 +200,7 @@
<div class="search-hero">
<div class="search-hero-input-wrap">
<span class="search-hero-icon" id="searchIcon"></span>
<input type="text" id="searchInput" placeholder="Suchen nach Personen, Locations, Veranstaltungen…"
<input type="text" id="searchInput" placeholder="Suchen nach Personen, Locations, Veranstaltungen, Gruppen…"
autocomplete="off" spellcheck="false">
</div>
</div>
@@ -188,6 +215,12 @@
<button class="search-tab-btn" data-tab="events">
Veranstaltungen <span class="search-tab-count" id="countEvents">0</span>
</button>
<button class="search-tab-btn" data-tab="gruppen">
Gruppen <span class="search-tab-count" id="countGruppen">0</span>
</button>
<button class="search-tab-btn" data-tab="hashtags">
Hashtags <span class="search-tab-count" id="countHashtags">0</span>
</button>
</div>
<div class="search-panel active" id="panel-users">
@@ -207,6 +240,31 @@
<div class="search-grid" id="gridEvents"></div>
<button class="search-load-more" id="moreEvents" style="display:none;">Mehr laden</button>
</div>
<div class="search-panel" id="panel-gruppen">
<div class="search-loading" id="loadingGruppen" style="display:none;">Wird geladen…</div>
<div class="search-grid" id="gridGruppen"></div>
</div>
<div class="search-panel" id="panel-hashtags">
<div class="search-loading" id="loadingHashtags" style="display:none;">Wird geladen…</div>
<div id="gridHashtags" style="display:flex; flex-wrap:wrap; gap:0.6rem; padding-top:0.25rem;"></div>
</div>
</div>
</div>
<!-- Beitrittsanfrage Dialog -->
<div class="dialog-backdrop" id="searchJoinDialog">
<div class="dialog">
<h3>Beitrittsanfrage senden</h3>
<p id="searchJoinGroupName" style="font-weight:600; margin-bottom:0.5rem;"></p>
<label>Nachricht (optional)</label>
<textarea id="searchJoinNachricht" placeholder="Warum möchtest du beitreten?"></textarea>
<p class="message error" id="searchJoinError" style="display:none; margin-top:0.75rem;"></p>
<div class="dialog-actions">
<button class="secondary" id="searchJoinCancelBtn">Abbrechen</button>
<button id="searchJoinSendBtn">Anfrage senden</button>
</div>
</div>
</div>
@@ -279,10 +337,12 @@
document.getElementById('grid' + cap(t)).innerHTML = '';
document.getElementById('more' + cap(t)).style.display = 'none';
});
// Alle drei Typen parallel laden
// Alle Typen parallel laden
loadChunk('users');
loadChunk('locations');
loadChunk('events');
loadGruppen(q);
loadHashtags(q);
}
function clearAll() {
@@ -295,6 +355,12 @@
document.getElementById('count' + cap(t)).textContent = '0';
document.getElementById('loading' + cap(t)).style.display = 'none';
});
document.getElementById('gridGruppen').innerHTML = '';
document.getElementById('countGruppen').textContent = '0';
document.getElementById('loadingGruppen').style.display = 'none';
document.getElementById('gridHashtags').innerHTML = '';
document.getElementById('countHashtags').textContent = '0';
document.getElementById('loadingHashtags').style.display = 'none';
const url = new URL(window.location);
url.searchParams.delete('q');
history.replaceState(null, '', url);
@@ -374,14 +440,185 @@
});
}
// ── Gruppen-Suche ──
async function loadGruppen(q) {
const loadingEl = document.getElementById('loadingGruppen');
const grid = document.getElementById('gridGruppen');
loadingEl.style.display = '';
grid.innerHTML = '';
try {
const res = await fetch('/gruppen/search?q=' + encodeURIComponent(q));
if (!res.ok) throw new Error();
const data = await res.json();
document.getElementById('countGruppen').textContent = data.length;
if (!data.length) {
grid.innerHTML = '<div class="search-empty" style="grid-column:1/-1;">Keine Ergebnisse.</div>';
return;
}
data.forEach(g => grid.appendChild(buildGruppeCard(g)));
} catch (e) {
// ignore
} finally {
loadingEl.style.display = 'none';
}
}
function buildGruppeCard(g) {
const card = document.createElement('div');
card.className = 'search-card';
card.style.cssText = 'justify-content:space-between; cursor:pointer;';
card.addEventListener('click', () => { location.href = '/community/gruppe.html?gruppeId=' + g.gruppeId; });
const av = g.bild
? `<div class="search-card-avatar search-card-avatar--square"><img src="data:image/jpeg;base64,${esc(g.bild)}" alt=""></div>`
: `<div class="search-card-avatar search-card-avatar--square">👥</div>`;
const privBadge = g.isPrivate ? ' 🔒' : '';
const sub = g.memberCount + ' Mitglied' + (g.memberCount !== 1 ? 'er' : '');
const info = document.createElement('div');
info.style.cssText = 'display:flex; align-items:center; gap:0.75rem; min-width:0; flex:1;';
info.innerHTML = `${av}<div class="search-card-body"><div class="search-card-name">${esc(g.name)}${privBadge}</div><div class="search-card-sub">${esc(sub)}</div></div>`;
card.appendChild(info);
if (!g.myRole) {
const btn = document.createElement('button');
btn.style.cssText = 'font-size:0.78rem; padding:0.3rem 0.65rem; width:auto; margin:0; white-space:nowrap; flex-shrink:0; margin-left:0.5rem;';
if (g.myRequestStatus === 'AUSSTEHEND') {
btn.disabled = true;
btn.style.opacity = '0.6';
btn.textContent = 'Anfrage ausstehend';
} else if (g.isPrivate) {
btn.textContent = 'Anfrage senden';
btn.addEventListener('click', e => { e.stopPropagation(); openSearchJoinDialog(g.gruppeId, g.name); });
} else {
btn.textContent = 'Beitreten';
btn.addEventListener('click', e => { e.stopPropagation(); joinGruppeSearch(g.gruppeId, btn); });
}
card.appendChild(btn);
}
return card;
}
// ── Hashtag-Suche ──
async function loadHashtags(q) {
const loadingEl = document.getElementById('loadingHashtags');
const grid = document.getElementById('gridHashtags');
loadingEl.style.display = '';
grid.innerHTML = '';
try {
const raw = q.startsWith('#') ? q.slice(1) : q;
const res = await fetch('/hashtags/suggest?q=' + encodeURIComponent(raw) + '&limit=20');
if (!res.ok) throw new Error();
const tags = await res.json();
document.getElementById('countHashtags').textContent = tags.length;
if (!tags.length) {
grid.innerHTML = '<div class="search-empty">Keine Hashtags gefunden.</div>';
return;
}
tags.forEach(tag => {
const a = document.createElement('a');
a.className = 'hashtag-chip';
a.href = '/community/feed.html?tag=' + encodeURIComponent(tag);
a.textContent = '#' + tag;
grid.appendChild(a);
});
} catch (e) {
// ignore
} finally {
loadingEl.style.display = 'none';
}
}
async function joinGruppeSearch(gruppeId, btn) {
btn.disabled = true;
btn.textContent = '…';
try {
const res = await fetch('/gruppen/' + gruppeId + '/join', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}'
});
if (res.ok || res.status === 201) {
btn.textContent = 'Beigetreten ✓';
} else {
btn.disabled = false;
btn.textContent = 'Beitreten';
}
} catch (e) {
btn.disabled = false;
btn.textContent = 'Beitreten';
}
}
// ── Join-Dialog für Gruppen ──
let searchJoinGruppeId = null;
function openSearchJoinDialog(gruppeId, name) {
searchJoinGruppeId = gruppeId;
document.getElementById('searchJoinGroupName').textContent = name;
document.getElementById('searchJoinNachricht').value = '';
document.getElementById('searchJoinError').style.display = 'none';
document.getElementById('searchJoinDialog').classList.add('visible');
}
function closeSearchJoinDialog() {
document.getElementById('searchJoinDialog').classList.remove('visible');
searchJoinGruppeId = null;
}
async function sendSearchJoinRequest() {
if (!searchJoinGruppeId) return;
document.getElementById('searchJoinError').style.display = 'none';
const nachricht = document.getElementById('searchJoinNachricht').value.trim() || null;
try {
const res = await fetch('/gruppen/' + searchJoinGruppeId + '/join', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nachricht })
});
if (res.ok || res.status === 201) {
closeSearchJoinDialog();
if (currentQuery.length >= 2) loadGruppen(currentQuery);
} else {
const el = document.getElementById('searchJoinError');
el.textContent = 'Fehler beim Senden der Anfrage.';
el.style.display = 'block';
}
} catch (e) {
const el = document.getElementById('searchJoinError');
el.textContent = 'Fehler: ' + e.message;
el.style.display = 'block';
}
}
document.getElementById('searchJoinCancelBtn').addEventListener('click', closeSearchJoinDialog);
document.getElementById('searchJoinSendBtn').addEventListener('click', sendSearchJoinRequest);
document.getElementById('searchJoinDialog').addEventListener('click', e => {
if (e.target === document.getElementById('searchJoinDialog')) closeSearchJoinDialog();
});
function cap(s) { return s.charAt(0).toUpperCase() + s.slice(1); }
// ── URL-Parameter beim Start auslesen ──
const initQ = new URLSearchParams(window.location.search).get('q') || '';
const params = new URLSearchParams(window.location.search);
const initQ = params.get('q') || '';
const initTab = params.get('tab') || '';
if (initTab) {
const tabBtn = document.querySelector(`.search-tab-btn[data-tab="${initTab}"]`);
if (tabBtn) tabBtn.click();
}
if (initQ.length >= 2) {
input.value = initQ;
// Warten bis icons.js geladen ist
setTimeout(() => startSearch(initQ), 100);
} else if (initTab === 'hashtags') {
// Populäre Tags zeigen wenn kein Suchbegriff
setTimeout(() => loadHashtags(''), 100);
}
})();
</script>