PageSourceSearch

https://www.fernstudi.net/media/com_ontemplate/js/autocomplete.init.js?2.0.0

js fernstudi.net collected 2026-10-02 06:36:41 UTC 12,392 bytes, 421 lines download raw bytes

1/**
2 * Grouped autosuggest search with server-side filtering.
3 *
4 * @package  ontheme
5 * @author   onblur.media <[email protected]>
6 */
7document.addEventListener('DOMContentLoaded', () => {
8  const input = document.getElementById('search-content-input');
9  const container = document.getElementById('modal-search-results');
10  const modal = document.getElementById('modal-search');
11
12  if (!input || !container || !modal) {
13    return;
14  }
15
16  const lang = input.getAttribute('data-lang') || 'de-DE';
17  const DEBOUNCE_MS = 300;
18  const MIN_CHARS = 2;
19
20  let debounceTimer = null;
21  let abortController = null;
22  let activeIndex = -1;
23  let lastQuery = '';
24  let trendingLoaded = false;
25
26  // Group labels from inline JS constants
27  const GROUP_LABELS = {
28    courses: typeof TPL_ONTHEME_AUTOSUGGEST_GROUP_COURSES !== 'undefined' ? TPL_ONTHEME_AUTOSUGGEST_GROUP_COURSES : 'Kurse',
29    categories: typeof TPL_ONTHEME_AUTOSUGGEST_GROUP_CATEGORIES !== 'undefined' ? TPL_ONTHEME_AUTOSUGGEST_GROUP_CATEGORIES : 'Kategorien',
30    vendors: typeof TPL_ONTHEME_AUTOSUGGEST_GROUP_VENDORS !== 'undefined' ? TPL_ONTHEME_AUTOSUGGEST_GROUP_VENDORS : 'Anbieter',
31    articles: typeof TPL_ONTHEME_AUTOSUGGEST_GROUP_ARTICLES !== 'undefined' ? TPL_ONTHEME_AUTOSUGGEST_GROUP_ARTICLES : 'Magazin',
32    tutorials: typeof TPL_ONTHEME_AUTOSUGGEST_GROUP_TUTORIALS !== 'undefined' ? TPL_ONTHEME_AUTOSUGGEST_GROUP_TUTORIALS : 'Tutorials',
33    news: typeof TPL_ONTHEME_AUTOSUGGEST_GROUP_NEWS !== 'undefined' ? TPL_ONTHEME_AUTOSUGGEST_GROUP_NEWS : 'News',
34    media: typeof TPL_ONTHEME_AUTOSUGGEST_GROUP_MEDIA !== 'undefined' ? TPL_ONTHEME_AUTOSUGGEST_GROUP_MEDIA : 'Medien',
35    questions: typeof TPL_ONTHEME_AUTOSUGGEST_GROUP_QUESTIONS !== 'undefined' ? TPL_ONTHEME_AUTOSUGGEST_GROUP_QUESTIONS : 'Fragen',
36    users: typeof TPL_ONTHEME_AUTOSUGGEST_GROUP_USERS !== 'undefined' ? TPL_ONTHEME_AUTOSUGGEST_GROUP_USERS : 'Community',
37    events: typeof TPL_ONTHEME_AUTOSUGGEST_GROUP_EVENTS !== 'undefined' ? TPL_ONTHEME_AUTOSUGGEST_GROUP_EVENTS : 'Events',
38    jobs: typeof TPL_ONTHEME_AUTOSUGGEST_GROUP_JOBS !== 'undefined' ? TPL_ONTHEME_AUTOSUGGEST_GROUP_JOBS : 'Jobs',
39  };
40
41  const TYPE_NAMES = {
42    0: 'course', 1: 'article', 2: 'video', 3: 'podcast',
43    4: 'news', 5: 'tutorial', 6: 'category', 7: 'question',
44    8: 'vendor', 9: 'user', 10: 'event', 11: 'job',
45  };
46
47  /**
48   * Fetch search results from the server.
49   */
50  async function fetchResults(query, typeFilter) {
51    if (abortController) {
52      abortController.abort();
53    }
54    abortController = new AbortController();
55
56    let url = '/index.php?option=com_ontemplate&task=search.suggest'
57      + '&q=' + encodeURIComponent(query)
58      + '&lang=' + encodeURIComponent(lang)
59      + '&format=json';
60
61    if (typeFilter) {
62      url += '&type=' + encodeURIComponent(typeFilter);
63    }
64
65    input.classList.add('is-loading');
66
67    try {
68      const response = await fetch(url, { signal: abortController.signal });
69
70      if (response.status === 429) {
71        return null;
72      }
73
74      return await response.json();
75    } catch (e) {
76      if (e.name !== 'AbortError') {
77        console.warn('Search fetch failed:', e);
78      }
79      return null;
80    } finally {
81      input.classList.remove('is-loading');
82    }
83  }
84
85  /**
86   * Fetch trending terms (once per session).
87   */
88  async function fetchTrending() {
89    const cached = sessionStorage.getItem('search_trending_' + lang);
90
91    if (cached) {
92      return JSON.parse(cached);
93    }
94
95    try {
96      const response = await fetch(
97        '/index.php?option=com_ontemplate&task=search.trending&lang='
98        + encodeURIComponent(lang) + '&format=json'
99      );
100      const terms = await response.json();
101
102      if (Array.isArray(terms) && terms.length > 0) {
103        sessionStorage.setItem('search_trending_' + lang, JSON.stringify(terms));
104        return terms;
105      }
106    } catch (e) {
107      // Silent fail
108    }
109
110    return [];
111  }
112
113  /**
114   * Highlight matching words in text.
115   */
116  function highlight(text, query) {
117    if (!query) {
118      return text;
119    }
120
121    const words = query.trim().split(/\s+/).filter(w => w.length >= 2);
122    let result = text;
123
124    words.forEach(word => {
125      const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
126      const regex = new RegExp('(' + escaped + ')', 'gi');
127      result = result.replace(regex, '<mark>$1</mark>');
128    });
129
130    return result;
131  }
132
133  /**
134   * Render grouped search results.
135   */
136  function renderResults(data, query) {
137    container.innerHTML = '';
138    activeIndex = -1;
139
140    if (!data || typeof data !== 'object') {
141      return;
142    }
143
144    const groups = Object.keys(data);
145
146    if (groups.length === 0) {
147      renderNoResults();
148      return;
149    }
150
151    const wrapper = document.createElement('div');
152    wrapper.className = 'search-results';
153    wrapper.setAttribute('role', 'listbox');
154    wrapper.setAttribute('aria-label', 'Search results');
155
156    groups.forEach(groupKey => {
157      const items = data[groupKey];
158
159      if (!Array.isArray(items) || items.length === 0) {
160        return;
161      }
162
163      // Section header
164      const header = document.createElement('div');
165      header.className = 'search-results__group-header';
166      header.textContent = GROUP_LABELS[groupKey] || groupKey;
167      wrapper.appendChild(header);
168
169      // Items
170      items.forEach(item => {
171        const el = document.createElement('a');
172        el.className = 'search-results__item';
173        el.href = item.u;
174        el.setAttribute('role', 'option');
175        el.setAttribute('data-tt', item.tt);
176        el.setAttribute('data-id', item.id);
177
178        let html = '';
179
180        // Thumbnail
181        html += '<div class="search-results__thumb">';
182        if (item.i) {
183          html += '<img src="' + item.i + '" alt="" loading="lazy">';
184        }
185        html += '</div>';
186
187        // Content
188        html += '<div class="search-results__content">';
189        html += '<div class="search-results__title">' + highlight(item.t, query) + '</div>';
190
191        // Subline based on type
192        if (item.v) {
193          html += '<div class="search-results__sub">' + item.v + '</div>';
194        }
195        if (item.d) {
196          const date = new Date(item.d);
197          html += '<div class="search-results__sub">' + date.toLocaleDateString('de-DE') + '</div>';
198        }
199        if (item.c) {
200          html += '<div class="search-results__sub">' + item.c + '</div>';
201        }
202
203        // Rating stars
204        if (item.r && item.r > 0.5) {
205          const width = Math.max(0, Math.min(5, item.r)) * 16;
206          html += '<span class="stars"><span style="width:' + width + 'px"></span></span>';
207        }
208
209        html += '</div>';
210        el.innerHTML = html;
211
212        el.addEventListener('click', (e) => {
213          trackClick(query, item);
214        });
215
216        wrapper.appendChild(el);
217      });
218    });
219
220    container.appendChild(wrapper);
221  }
222
223  /**
224   * Render "no results" message.
225   */
226  function renderNoResults() {
227    const msg = typeof TPL_ONTHEME_AUTOSUGGEST_NOTHING_FOUND !== 'undefined'
228      ? TPL_ONTHEME_AUTOSUGGEST_NOTHING_FOUND
229      : 'Keine Ergebnisse gefunden.';
230    container.innerHTML = '<div class="search-results__empty">' + msg + '</div>';
231  }
232
233  /**
234   * Render trending terms.
235   */
236  function renderTrending(terms) {
237    if (!terms || terms.length === 0) {
238      return;
239    }
240
241    let html = '<div class="search-trending">';
242    html += '<div class="search-trending__label">';
243    html += (typeof TPL_ONTHEME_AUTOSUGGEST_TRENDING !== 'undefined'
244      ? TPL_ONTHEME_AUTOSUGGEST_TRENDING : 'Beliebte Suchen');
245    html += '</div>';
246    html += '<div class="search-trending__tags">';
247
248    terms.forEach(term => {
249      html += '<button type="button" class="search-trending__tag">'
250        + term + '</button>';
251    });
252
253    html += '</div></div>';
254    container.innerHTML = html;
255
256    container.querySelectorAll('.search-trending__tag').forEach(btn => {
257      btn.addEventListener('click', () => {
258        input.value = btn.textContent;
259        triggerSearch(btn.textContent);
260      });
261    });
262  }
263
264  /**
265   * Matomo tracking on result click.
266   */
267  function trackClick(query, item) {
268    if (typeof _paq === 'undefined') {
269      return;
270    }
271
272    _paq.push(['trackSiteSearch', query.toLowerCase(), false, false]);
273    const typeName = TYPE_NAMES[item.tt] || 'unknown';
274    _paq.push(['trackEvent', 'com_ontemplate', 'click-autosuggest', typeName + '-' + item.id]);
275  }
276
277  /**
278   * Trigger a search request.
279   */
280  async function triggerSearch(query, typeFilter) {
281    lastQuery = query;
282
283    if (query.length < MIN_CHARS) {
284      if (!trendingLoaded) {
285        const terms = await fetchTrending();
286        renderTrending(terms);
287        trendingLoaded = true;
288      } else if (query.length === 0) {
289        const cached = sessionStorage.getItem('search_trending_' + lang);
290        if (cached) {
291          renderTrending(JSON.parse(cached));
292        }
293      }
294      return;
295    }
296
297    const data = await fetchResults(query, typeFilter);
298
299    if (data !== null && query === lastQuery) {
300      renderResults(data, query);
301    }
302  }
303
304  /**
305   * Get the currently active filter type from chips.
306   */
307  function getActiveFilter() {
308    const filtersEl = document.getElementById('search-filters');
309
310    if (!filtersEl) {
311      return '';
312    }
313
314    const active = filtersEl.querySelector('.search-filters__btn.active[data-type]');
315    return active ? active.getAttribute('data-type') : '';
316  }
317
318  // Debounced input handler
319  input.addEventListener('input', () => {
320    clearTimeout(debounceTimer);
321    debounceTimer = setTimeout(() => {
322      triggerSearch(input.value.trim(), getActiveFilter() || undefined);
323    }, DEBOUNCE_MS);
324  });
325
326  // Filter chip click handlers
327  const filtersEl = document.getElementById('search-filters');
328
329  if (filtersEl) {
330    filtersEl.addEventListener('click', (e) => {
331      const btn = e.target.closest('.search-filters__btn');
332
333      if (!btn) {
334        return;
335      }
336
337      filtersEl.querySelectorAll('.search-filters__btn').forEach(b => {
338        b.classList.remove('active');
339        b.setAttribute('aria-selected', 'false');
340      });
341      btn.classList.add('active');
342      btn.setAttribute('aria-selected', 'true');
343
344      const query = input.value.trim();
345      const typeFilter = btn.getAttribute('data-type');
346
347      if (query.length >= MIN_CHARS) {
348        triggerSearch(query, typeFilter || undefined);
349      }
350
351      input.focus();
352    });
353  }
354
355  // Keyboard navigation
356  input.addEventListener('keydown', (e) => {
357    const items = container.querySelectorAll('.search-results__item');
358
359    if (!items.length) {
360      return;
361    }
362
363    if (e.key === 'ArrowDown') {
364      e.preventDefault();
365      activeIndex = Math.min(activeIndex + 1, items.length - 1);
366      updateActiveItem(items);
367    } else if (e.key === 'ArrowUp') {
368      e.preventDefault();
369      activeIndex = Math.max(activeIndex - 1, -1);
370      updateActiveItem(items);
371    } else if (e.key === 'Enter' && activeIndex >= 0) {
372      e.preventDefault();
373      items[activeIndex].click();
374    }
375  });
376
377  /**
378   * Update visual state of active keyboard-navigated item.
379   */
380  function updateActiveItem(items) {
381    items.forEach((item, i) => {
382      item.classList.toggle('is-active', i === activeIndex);
383
384      if (i === activeIndex) {
385        item.setAttribute('aria-selected', 'true');
386        item.scrollIntoView({ block: 'nearest' });
387        input.setAttribute('aria-activedescendant', 'search-result-' + i);
388        item.id = 'search-result-' + i;
389      } else {
390        item.removeAttribute('aria-selected');
391      }
392    });
393
394    if (activeIndex < 0) {
395      input.removeAttribute('aria-activedescendant');
396    }
397  }
398
399  // Show trending on modal open
400  modal.addEventListener('shown.bs.modal', async () => {
401    input.focus();
402
403    if (input.value.trim().length < MIN_CHARS) {
404      const terms = await fetchTrending();
405      renderTrending(terms);
406      trendingLoaded = true;
407    }
408  });
409
410  // Prevent blur from closing results
411  input.addEventListener('blur', (e) => {
412    e.stopImmediatePropagation();
413  }, true);
414
415  // ARIA attributes
416  input.setAttribute('role', 'combobox');
417  input.setAttribute('aria-autocomplete', 'list');
418  input.setAttribute('aria-expanded', 'false');
419  input.setAttribute('aria-controls', 'modal-search-results');
420  container.setAttribute('aria-live', 'polite');
421});

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.