Files
xxx-sphere-web/bin/main/static/search.html
Mario e2a71ab096
Some checks failed
Host-Based Deploy (Java 21 Fix) / build-and-run (push) Has been cancelled
Hashtags eingeführt
2026-04-11 01:14:33 +02:00

627 lines
26 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Suche xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.search-hero {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.search-hero-input-wrap {
flex: 1;
position: relative;
display: flex;
align-items: center;
}
.search-hero-icon {
position: absolute;
left: 0.85rem;
color: var(--color-muted);
display: flex;
align-items: center;
pointer-events: none;
}
#searchInput {
width: 100%;
padding: 0.65rem 1rem 0.65rem 2.6rem;
border: 1px solid var(--color-secondary);
border-radius: 8px;
background: var(--color-card);
color: var(--color-text);
font-size: 1rem;
outline: none;
transition: border-color 0.2s;
box-sizing: border-box;
}
#searchInput:focus { border-color: var(--color-primary); }
.search-tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--color-secondary);
margin-bottom: 1.25rem;
}
.search-tab-btn {
background: none;
border: none;
border-bottom: 3px solid transparent;
border-radius: 0;
padding: 0.55rem 1.1rem;
font-size: 0.9rem;
font-weight: 600;
color: var(--color-muted);
cursor: pointer;
margin-bottom: -1px;
transition: color 0.15s, border-color 0.15s;
}
.search-tab-btn:hover { color: var(--color-text); background: none; }
.search-tab-btn.active { color: var(--color-primary); border-bottom-color: var(--color-primary); }
.search-tab-count {
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--color-secondary);
color: var(--color-muted);
border-radius: 10px;
font-size: 0.7rem;
font-weight: 700;
min-width: 1.2em;
padding: 0 0.35em;
margin-left: 0.3em;
vertical-align: middle;
}
.search-tab-btn.active .search-tab-count {
background: var(--color-primary);
color: #fff;
}
.search-panel { display: none; }
.search-panel.active { display: block; }
.search-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 0.85rem;
}
.search-card {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 10px;
padding: 0.85rem;
display: flex;
align-items: center;
gap: 0.75rem;
text-decoration: none;
color: var(--color-text);
transition: border-color 0.15s;
min-width: 0;
}
.search-card:hover { border-color: var(--color-primary); }
.search-card-avatar {
width: 44px;
height: 44px;
border-radius: 50%;
object-fit: cover;
background: var(--color-secondary);
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.3rem;
overflow: hidden;
}
.search-card-avatar img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
.search-card-avatar--square { border-radius: 8px; }
.search-card-avatar--square img { border-radius: 6px; }
.search-card-body { min-width: 0; }
.search-card-name {
font-size: 0.9rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.search-card-sub {
font-size: 0.75rem;
color: var(--color-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.search-empty {
text-align: center;
padding: 2.5rem 1rem;
color: var(--color-muted);
font-size: 0.9rem;
}
.search-loading {
text-align: center;
padding: 2rem 1rem;
color: var(--color-muted);
font-size: 0.9rem;
}
.search-load-more {
display: block;
width: 100%;
margin-top: 1rem;
padding: 0.6rem;
background: none;
border: 1px solid var(--color-secondary);
border-radius: 8px;
color: var(--color-muted);
cursor: pointer;
font-size: 0.88rem;
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">
<div class="main">
<div class="content">
<h1 style="margin-bottom:1rem;">Suche</h1>
<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, Gruppen…"
autocomplete="off" spellcheck="false">
</div>
</div>
<div class="search-tabs">
<button class="search-tab-btn active" data-tab="users">
Personen <span class="search-tab-count" id="countUsers">0</span>
</button>
<button class="search-tab-btn" data-tab="locations">
Locations <span class="search-tab-count" id="countLocations">0</span>
</button>
<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">
<div class="search-loading" id="loadingUsers" style="display:none;">Wird geladen…</div>
<div class="search-grid" id="gridUsers"></div>
<button class="search-load-more" id="moreUsers" style="display:none;">Mehr laden</button>
</div>
<div class="search-panel" id="panel-locations">
<div class="search-loading" id="loadingLocations" style="display:none;">Wird geladen…</div>
<div class="search-grid" id="gridLocations"></div>
<button class="search-load-more" id="moreLocations" style="display:none;">Mehr laden</button>
</div>
<div class="search-panel" id="panel-events">
<div class="search-loading" id="loadingEvents" style="display:none;">Wird geladen…</div>
<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>
<script src="/js/icons.js"></script>
<script src="/js/nav.js"></script>
<script>
(function () {
const PAGE_SIZE = 24;
function esc(s) {
return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function IC(key) { return window.IC ? window.IC(key) : (window.ICONS?.[key]?.value || ''); }
// ── Suchicon setzen ──
document.addEventListener('DOMContentLoaded', () => {
const icon = document.getElementById('searchIcon');
if (icon) icon.innerHTML = IC('SEARCH') || '🔍';
});
setTimeout(() => {
const icon = document.getElementById('searchIcon');
if (icon && !icon.innerHTML) icon.innerHTML = IC('SEARCH') || '🔍';
}, 200);
// ── State ──
const state = {
users: { offset: 0, total: 0, loading: false },
locations: { offset: 0, total: 0, loading: false },
events: { offset: 0, total: 0, loading: false }
};
let currentQuery = '';
let debounceTimer;
// ── Tabs ──
document.querySelectorAll('.search-tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.search-tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.search-panel').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
document.getElementById('panel-' + btn.dataset.tab).classList.add('active');
});
});
// ── "Mehr laden" Buttons ──
document.getElementById('moreUsers').addEventListener('click', () => loadMore('users'));
document.getElementById('moreLocations').addEventListener('click', () => loadMore('locations'));
document.getElementById('moreEvents').addEventListener('click', () => loadMore('events'));
// ── Suche starten ──
const input = document.getElementById('searchInput');
input.addEventListener('input', () => {
clearTimeout(debounceTimer);
const q = input.value.trim();
if (q.length < 2) { clearAll(); return; }
debounceTimer = setTimeout(() => startSearch(q), 300);
});
function startSearch(q) {
currentQuery = q;
// URL aktualisieren
const url = new URL(window.location);
url.searchParams.set('q', q);
history.replaceState(null, '', url);
// State zurücksetzen
['users', 'locations', 'events'].forEach(t => {
state[t].offset = 0;
state[t].total = 0;
document.getElementById('grid' + cap(t)).innerHTML = '';
document.getElementById('more' + cap(t)).style.display = 'none';
});
// Alle Typen parallel laden
loadChunk('users');
loadChunk('locations');
loadChunk('events');
loadGruppen(q);
loadHashtags(q);
}
function clearAll() {
currentQuery = '';
['users', 'locations', 'events'].forEach(t => {
state[t].offset = 0;
state[t].total = 0;
document.getElementById('grid' + cap(t)).innerHTML = '';
document.getElementById('more' + cap(t)).style.display = 'none';
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);
}
function loadMore(type) {
if (state[type].loading) return;
loadChunk(type);
}
async function loadChunk(type) {
if (!currentQuery || state[type].loading) return;
state[type].loading = true;
const loadingEl = document.getElementById('loading' + cap(type));
const moreBtn = document.getElementById('more' + cap(type));
loadingEl.style.display = '';
moreBtn.style.display = 'none';
try {
const url = `/search?q=${encodeURIComponent(currentQuery)}&limit=${PAGE_SIZE}&offset=${state[type].offset}&type=${type}`;
const res = await fetch(url);
if (!res.ok) throw new Error();
const data = await res.json();
const items = data[type] || [];
state[type].offset += items.length;
state[type].total = data.total?.[type] ?? (items.length < PAGE_SIZE ? state[type].offset : state[type].offset + 1);
appendItems(type, items);
// Zähler aktualisieren
document.getElementById('count' + cap(type)).textContent = state[type].offset;
// "Mehr laden" zeigen wenn noch mehr vorhanden
moreBtn.style.display = items.length === PAGE_SIZE ? '' : 'none';
} catch (e) {
// ignore
} finally {
state[type].loading = false;
loadingEl.style.display = 'none';
}
}
function appendItems(type, items) {
const grid = document.getElementById('grid' + cap(type));
if (!items.length && state[type].offset === 0) {
if (!grid.innerHTML) {
grid.innerHTML = `<div class="search-empty" style="grid-column:1/-1;">Keine Ergebnisse.</div>`;
}
return;
}
items.forEach(item => {
const card = document.createElement('a');
if (type === 'users') {
card.href = `/community/benutzer.html?userId=${esc(item.userId)}`;
const av = item.profilePicture
? `<div class="search-card-avatar"><img src="data:image/png;base64,${esc(item.profilePicture)}" alt=""></div>`
: `<div class="search-card-avatar">${IC('PROFILE') || '👤'}</div>`;
card.innerHTML = `${av}<div class="search-card-body"><div class="search-card-name">${esc(item.name)}</div></div>`;
} else if (type === 'locations') {
card.href = `/community/location-detail.html?id=${esc(item.locationId)}`;
const av = item.profilePicture
? `<div class="search-card-avatar search-card-avatar--square"><img src="data:image/png;base64,${esc(item.profilePicture)}" alt=""></div>`
: `<div class="search-card-avatar search-card-avatar--square">📍</div>`;
card.innerHTML = `${av}<div class="search-card-body"><div class="search-card-name">${esc(item.name)}</div></div>`;
} else {
card.href = `/community/event-detail.html?eventId=${esc(item.eventId)}`;
const av = item.imageData
? `<div class="search-card-avatar search-card-avatar--square"><img src="${esc(item.imageData)}" alt=""></div>`
: `<div class="search-card-avatar search-card-avatar--square">🗓</div>`;
const sub = item.startAt ? new Date(item.startAt).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' }) : '';
card.innerHTML = `${av}<div class="search-card-body"><div class="search-card-name">${esc(item.title)}</div><div class="search-card-sub">${esc(sub)}</div></div>`;
}
card.className = 'search-card';
grid.appendChild(card);
});
}
// ── 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 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;
setTimeout(() => startSearch(initQ), 100);
} else if (initTab === 'hashtags') {
// Populäre Tags zeigen wenn kein Suchbegriff
setTimeout(() => loadHashtags(''), 100);
}
})();
</script>
</body>
</html>