init
release-tag / release-image (push) Successful in 1m35s

This commit is contained in:
2026-07-28 23:11:40 +02:00
parent 8eb01af82e
commit e5bc62ebf2
23 changed files with 3976 additions and 1 deletions
+382
View File
@@ -0,0 +1,382 @@
(() => {
'use strict';
const $ = (selector, root = document) => root.querySelector(selector);
const state = {
query: '',
page: 1,
pageSize: 20,
total: 0,
totalPages: 0,
facets: null,
config: null,
currentKey: null,
currentDoc: null,
};
const els = {
brandTitle: $('#brandTitle'), brandSubtitle: $('#brandSubtitle'), countBadge: $('#countBadge'),
hero: $('#hero'), heroSearchForm: $('#heroSearchForm'), heroSearch: $('#heroSearch'), quickLinks: $('#quickLinks'),
resultsView: $('#resultsView'), topSearchForm: $('#topSearchForm'), topSearch: $('#topSearch'),
resultCount: $('#resultCount'), resultHint: $('#resultHint'), clearSearch: $('#clearSearch'), sideFacets: $('#sideFacets'),
loading: $('#loading'), noResults: $('#noResults'), resultList: $('#resultList'), pagination: $('#pagination'),
articleDialog: $('#articleDialog'), closeArticle: $('#closeArticle'), articleEyebrow: $('#articleEyebrow'),
articleTitle: $('#articleTitle'), articleMeta: $('#articleMeta'), problemSection: $('#problemSection'),
articleProblem: $('#articleProblem'), answerSection: $('#answerSection'), articleAnswer: $('#articleAnswer'),
tagsSection: $('#tagsSection'), articleTags: $('#articleTags'), sourceSection: $('#sourceSection'),
articleSource: $('#articleSource'), articleSourceUri: $('#articleSourceUri'), sourceLink: $('#sourceLink'),
articlePath: $('#articlePath'), copyAnswer: $('#copyAnswer'), copyLink: $('#copyLink'), toastHost: $('#toastHost'),
};
async function api(url) {
const response = await fetch(url, {headers: {'Accept': 'application/json'}});
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.error || `${response.status} ${response.statusText}`);
return body;
}
function escapeHTML(value) {
return String(value ?? '').replace(/[&<>'"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c]));
}
function escapeRegex(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function highlighted(value, query = state.query) {
let safe = escapeHTML(value);
const terms = [...new Set(String(query).trim().split(/\s+/).filter(Boolean))]
.sort((a, b) => b.length - a.length);
if (!terms.length) return safe;
const regex = new RegExp(`(${terms.map(escapeRegex).join('|')})`, 'gi');
return safe.replace(regex, '<mark>$1</mark>');
}
function qs(params) {
const out = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== '' && value !== null && value !== undefined) out.set(key, String(value));
});
return out.toString();
}
function updateURL({replace = false} = {}) {
const params = new URLSearchParams();
if (state.query) params.set('q', state.query);
if (state.page > 1) params.set('page', String(state.page));
if (state.currentKey) params.set('doc', state.currentKey);
const url = `${location.pathname}${params.toString() ? `?${params}` : ''}`;
history[replace ? 'replaceState' : 'pushState']({}, '', url);
}
async function loadBootstrap() {
try {
const [config, health, facets] = await Promise.all([
api('/api/config'), api('/api/health'), api('/api/facets?limit=10')
]);
state.config = config;
state.facets = facets;
document.title = config.title || 'Helpdesk Search';
els.brandTitle.textContent = config.title || 'Helpdesk Search';
els.brandSubtitle.textContent = config.subtitle || 'Interne Wissenssuche für den Helpdesk';
els.countBadge.textContent = `${Number(health.count || 0).toLocaleString('de-DE')} Wissenseinträge`;
renderFacets();
} catch (error) {
els.countBadge.textContent = 'Wissensbasis nicht erreichbar';
toast(error.message, 'error');
}
}
function renderFacets() {
const categories = (state.facets?.categories?.length ? state.facets.categories : state.facets?.keywords) || [];
els.quickLinks.innerHTML = '';
els.sideFacets.innerHTML = '';
categories.slice(0, 7).forEach((facet) => {
const heroButton = document.createElement('button');
heroButton.type = 'button';
heroButton.className = 'quick-chip';
heroButton.innerHTML = `<span>${escapeHTML(facet.name)}</span><small>${facet.count.toLocaleString('de-DE')}</small>`;
heroButton.addEventListener('click', () => submitSearch(facet.name));
els.quickLinks.appendChild(heroButton);
const sideButton = document.createElement('button');
sideButton.type = 'button';
sideButton.className = 'facet-button';
sideButton.innerHTML = `<span>${escapeHTML(facet.name)}</span><small>${facet.count.toLocaleString('de-DE')}</small>`;
sideButton.addEventListener('click', () => submitSearch(facet.name));
els.sideFacets.appendChild(sideButton);
});
}
async function runSearch() {
const query = state.query.trim();
if (!query) {
showHome();
return;
}
showResults();
els.loading.classList.remove('hidden');
els.noResults.classList.add('hidden');
els.resultList.innerHTML = '';
els.pagination.classList.add('hidden');
try {
const data = await api(`/api/search?${qs({q: query, page: state.page, page_size: state.pageSize})}`);
state.page = data.page || 1;
state.total = data.total || 0;
state.totalPages = data.total_pages || 0;
renderResults(data.items || []);
renderPagination();
els.resultCount.textContent = `${state.total.toLocaleString('de-DE')} ${state.total === 1 ? 'Treffer' : 'Treffer'}`;
els.resultHint.textContent = `für „${query}“`;
if (!state.total) els.noResults.classList.remove('hidden');
} catch (error) {
els.resultCount.textContent = 'Suche fehlgeschlagen';
els.resultHint.textContent = '';
toast(error.message, 'error');
} finally {
els.loading.classList.add('hidden');
}
}
function renderResults(items) {
els.resultList.innerHTML = '';
for (const item of items) {
const card = document.createElement('article');
card.className = 'result-card';
const tags = [...(item.categories || []), ...(item.keywords || [])].slice(0, 4);
card.innerHTML = `
<button class="result-main" type="button">
<div class="result-overline">
<span class="result-id">${highlighted(item.id || item.rel_path)}</span>
${item.source ? `<span class="result-source">${escapeHTML(item.source)}</span>` : ''}
</div>
<h2>${highlighted(item.title || '(ohne Titel)')}</h2>
${item.excerpt ? `<p>${highlighted(item.excerpt)}</p>` : '<p class="muted">Kein Beschreibungstext hinterlegt.</p>'}
<div class="result-tags">${tags.map(tag => `<span>${highlighted(tag)}</span>`).join('')}</div>
</button>
<div class="result-arrow" aria-hidden="true">→</div>`;
$('.result-main', card).addEventListener('click', () => openArticle(item.key));
card.addEventListener('dblclick', () => openArticle(item.key));
els.resultList.appendChild(card);
}
}
function renderPagination() {
els.pagination.innerHTML = '';
if (state.totalPages <= 1) {
els.pagination.classList.add('hidden');
return;
}
els.pagination.classList.remove('hidden');
const add = (label, page, {active = false, disabled = false, aria = ''} = {}) => {
const button = document.createElement('button');
button.type = 'button';
button.textContent = label;
button.className = `page-btn${active ? ' active' : ''}`;
button.disabled = disabled;
if (aria) button.setAttribute('aria-label', aria);
button.addEventListener('click', () => goPage(page));
els.pagination.appendChild(button);
};
add('‹', state.page - 1, {disabled: state.page <= 1, aria: 'Vorherige Seite'});
const pages = pageWindow(state.page, state.totalPages);
let previous = 0;
pages.forEach(page => {
if (previous && page - previous > 1) {
const gap = document.createElement('span');
gap.className = 'page-gap';
gap.textContent = '…';
els.pagination.appendChild(gap);
}
add(String(page), page, {active: page === state.page, aria: `Seite ${page}`});
previous = page;
});
add('›', state.page + 1, {disabled: state.page >= state.totalPages, aria: 'Nächste Seite'});
}
function pageWindow(current, total) {
const candidates = new Set([1, total]);
for (let page = current - 2; page <= current + 2; page++) {
if (page >= 1 && page <= total) candidates.add(page);
}
return [...candidates].sort((a, b) => a - b);
}
function goPage(page) {
if (page < 1 || page > state.totalPages || page === state.page) return;
state.page = page;
state.currentKey = null;
updateURL();
runSearch();
window.scrollTo({top: 0, behavior: 'smooth'});
}
function submitSearch(value) {
const query = String(value ?? '').trim();
if (!query) return;
state.query = query;
state.page = 1;
state.currentKey = null;
els.heroSearch.value = query;
els.topSearch.value = query;
updateURL();
runSearch();
}
function showHome() {
state.query = '';
state.page = 1;
state.currentKey = null;
els.hero.classList.remove('hidden');
els.resultsView.classList.add('hidden');
els.heroSearch.value = '';
updateURL({replace: true});
setTimeout(() => els.heroSearch.focus(), 0);
}
function showResults() {
els.hero.classList.add('hidden');
els.resultsView.classList.remove('hidden');
els.topSearch.value = state.query;
}
async function openArticle(key, {updateHistory = true} = {}) {
try {
const data = await api(`/api/items/${encodeURIComponent(key)}`);
state.currentKey = key;
state.currentDoc = data.document || {};
renderArticle(state.currentDoc, data.meta || {});
if (updateHistory) updateURL();
if (!els.articleDialog.open) els.articleDialog.showModal();
} catch (error) {
toast(error.message, 'error');
}
}
function renderArticle(doc, meta) {
els.articleEyebrow.textContent = doc.id || meta.rel_path || 'Wissensartikel';
els.articleTitle.textContent = doc.title || '(ohne Titel)';
els.articleProblem.textContent = doc.text || '';
els.articleAnswer.textContent = doc.answer || '';
els.problemSection.classList.toggle('hidden', !doc.text);
els.answerSection.classList.toggle('hidden', !doc.answer);
const metaParts = [];
if (doc.language) metaParts.push(doc.language);
if (doc.communication_style) metaParts.push(doc.communication_style);
if (typeof doc.auto_reply === 'boolean') metaParts.push(`auto_reply: ${doc.auto_reply}`);
if (doc.min_score !== undefined && doc.min_score !== null) metaParts.push(`min_score: ${doc.min_score}`);
els.articleMeta.innerHTML = metaParts.map(value => `<span>${escapeHTML(value)}</span>`).join('');
const tags = [...new Set([...(Array.isArray(doc.categories) ? doc.categories : []), ...(Array.isArray(doc.keywords) ? doc.keywords : [])])];
els.articleTags.innerHTML = tags.map(tag => `<span>${escapeHTML(tag)}</span>`).join('');
els.tagsSection.classList.toggle('hidden', tags.length === 0);
const source = String(doc.source || '').trim();
const sourceURI = safeURL(doc.source_uri);
els.articleSource.textContent = source || 'Quelle';
els.articleSourceUri.textContent = sourceURI || '';
els.sourceSection.classList.toggle('hidden', !source && !sourceURI);
els.sourceLink.classList.toggle('hidden', !sourceURI);
if (sourceURI) els.sourceLink.href = sourceURI;
else els.sourceLink.removeAttribute('href');
els.articlePath.textContent = meta.rel_path || '';
}
function safeURL(value) {
const raw = String(value || '').trim();
if (!raw) return '';
try {
const parsed = new URL(raw);
return ['http:', 'https:'].includes(parsed.protocol) ? parsed.href : '';
} catch (_) {
return '';
}
}
function closeArticle({updateHistory = true} = {}) {
if (els.articleDialog.open) els.articleDialog.close();
state.currentKey = null;
state.currentDoc = null;
if (updateHistory) updateURL({replace: true});
}
async function copyText(text, message) {
try {
await navigator.clipboard.writeText(text);
toast(message, 'success');
} catch (_) {
toast('Kopieren wurde vom Browser blockiert.', 'error');
}
}
function toast(message, type = '') {
const el = document.createElement('div');
el.className = `toast ${type}`;
el.textContent = message;
els.toastHost.appendChild(el);
setTimeout(() => el.remove(), 3200);
}
function bindEvents() {
els.heroSearchForm.addEventListener('submit', event => {
event.preventDefault();
submitSearch(els.heroSearch.value);
});
els.topSearchForm.addEventListener('submit', event => {
event.preventDefault();
submitSearch(els.topSearch.value);
});
els.clearSearch.addEventListener('click', showHome);
els.closeArticle.addEventListener('click', () => closeArticle());
els.articleDialog.addEventListener('click', event => {
if (event.target === els.articleDialog) closeArticle();
});
els.articleDialog.addEventListener('cancel', event => {
event.preventDefault();
closeArticle();
});
els.copyAnswer.addEventListener('click', () => copyText(String(state.currentDoc?.answer || ''), 'Antwort kopiert.'));
els.copyLink.addEventListener('click', () => copyText(location.href, 'Artikellink kopiert.'));
document.addEventListener('keydown', event => {
if (event.key === '/' && !['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName)) {
event.preventDefault();
(state.query ? els.topSearch : els.heroSearch).focus();
}
});
window.addEventListener('popstate', () => hydrateFromURL({historyNavigation: true}));
}
async function hydrateFromURL({historyNavigation = false} = {}) {
const params = new URLSearchParams(location.search);
state.query = (params.get('q') || '').trim();
state.page = Math.max(1, Number.parseInt(params.get('page') || '1', 10) || 1);
const docKey = params.get('doc') || '';
if (state.query) {
els.heroSearch.value = state.query;
els.topSearch.value = state.query;
await runSearch();
} else {
showHome();
}
if (docKey) await openArticle(docKey, {updateHistory: false});
else if (historyNavigation && els.articleDialog.open) closeArticle({updateHistory: false});
}
async function init() {
bindEvents();
await loadBootstrap();
await hydrateFromURL();
}
init();
})();