All checks were successful
release-tag / release-image (push) Successful in 1m35s
383 lines
14 KiB
JavaScript
383 lines
14 KiB
JavaScript
(() => {
|
||
'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 => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[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();
|
||
})();
|