PageSourceSearch

https://ai-teacher.de/js/header-search.js

js ai-teacher.de collected 2026-09-24 14:20:59 UTC 15,948 bytes, 520 lines download raw bytes

1(function () {
2  'use strict';
3
4  var MIN_CHARS = 2;
5  var DEBOUNCE_MS = 250;
6
7  function debounce(fn, delay) {
8    var timer;
9    return function () {
10      var args = arguments;
11      var context = this;
12      clearTimeout(timer);
13      timer = setTimeout(function () {
14        fn.apply(context, args);
15      }, delay);
16    };
17  }
18
19  function escapeHtml(text) {
20    var div = document.createElement('div');
21    div.textContent = String(text == null ? '' : text);
22    return div.innerHTML;
23  }
24
25  function escapeRegExp(value) {
26    return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
27  }
28
29  function highlight(text, query) {
30    var escaped = escapeHtml(text);
31    var safe = escapeRegExp(String(query || '').trim());
32    if (!safe || safe.length < MIN_CHARS) {
33      return escaped;
34    }
35    return escaped.replace(new RegExp('(' + safe + ')', 'gi'), '<mark>$1</mark>');
36  }
37
38  function buildCreateUrl(query, parsed) {
39    var params = new URLSearchParams();
40    var topics = (parsed && parsed.topics) ? parsed.topics : query;
41    if (topics) {
42      params.set('topics', topics);
43    }
44    if (parsed && parsed.course) {
45      params.set('course', parsed.course);
46    }
47    if (parsed && parsed.class) {
48      params.set('class', parsed.class);
49    }
50    var encoded = params.toString();
51    return encoded ? ('exercise.php?' + encoded) : 'exercise.php';
52  }
53
54  function trackHeaderSearch(details) {
55    var params = new URLSearchParams();
56    var userID = null;
57    try {
58      userID = window.localStorage.getItem('userID');
59    } catch (err) {
60      userID = null;
61    }
62    if (userID) {
63      params.append('userID', userID);
64    }
65    params.append('feature', 'header-search');
66    params.append('details', details);
67    fetch('writebackFeatureUsage.php', {
68      method: 'POST',
69      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
70      body: params.toString(),
71      keepalive: true,
72    }).catch(function () {});
73  }
74
75  function initHeaderSearch() {
76    var root = document.getElementById('headerSearch');
77    var btn = document.getElementById('headerSearchBtn');
78    var input = document.getElementById('headerSearchInput');
79    var clearBtn = document.getElementById('headerSearchClearBtn');
80    var resultsEl = document.getElementById('headerSearchResults');
81    var panel = document.getElementById('headerSearchPanel');
82    var backdrop = document.getElementById('headerSearchBackdrop');
83    if (!root || !btn || !input || !resultsEl) {
84      return;
85    }
86
87    var labels = {
88      noResults: root.getAttribute('data-no-results') || '',
89      create: root.getAttribute('data-create') || '',
90      classLabel: root.getAttribute('data-class-label') || 'Klasse',
91      open: root.getAttribute('data-open-label') || '',
92      close: root.getAttribute('data-close-label') || '',
93      createIcon: root.getAttribute('data-create-icon') || '',
94      loading: root.getAttribute('data-loading-label') || '',
95    };
96    var abortController = null;
97    var activeIndex = -1;
98    var latestQuery = '';
99
100    function isOpen() {
101      return root.classList.contains('is-open');
102    }
103
104    function selectableItems() {
105      return resultsEl.querySelectorAll('[data-search-selectable="1"]');
106    }
107
108    function setActive(index) {
109      var items = selectableItems();
110      if (!items.length) {
111        activeIndex = -1;
112        input.removeAttribute('aria-activedescendant');
113        return;
114      }
115      if (index < 0) {
116        index = items.length - 1;
117      }
118      if (index >= items.length) {
119        index = 0;
120      }
121      activeIndex = index;
122      items.forEach(function (item, i) {
123        item.classList.toggle('is-active', i === activeIndex);
124        if (i === activeIndex) {
125          item.setAttribute('aria-selected', 'true');
126          input.setAttribute('aria-activedescendant', item.id);
127          if (typeof item.scrollIntoView === 'function') {
128            item.scrollIntoView({ block: 'nearest' });
129          }
130        } else {
131          item.setAttribute('aria-selected', 'false');
132        }
133      });
134    }
135
136    function closeMobileNav() {
137      document.body.classList.remove('mobile-nav-active');
138    }
139
140    function setToggleLabel(open) {
141      var label = open ? labels.close : labels.open;
142      if (label) {
143        btn.setAttribute('aria-label', label);
144        btn.setAttribute('title', label);
145      }
146    }
147
148    function openSearch() {
149      closeMobileNav();
150      root.classList.add('is-open');
151      btn.setAttribute('aria-expanded', 'true');
152      setToggleLabel(true);
153      trackHeaderSearch('open');
154      if (panel) {
155        panel.inert = false;
156      }
157      if (backdrop) {
158        backdrop.hidden = false;
159      }
160      input.focus();
161      if (input.value.trim().length >= MIN_CHARS) {
162        renderShell(input.value.trim(), null, null, true);
163        runSearch(input.value.trim());
164      }
165    }
166
167    function closeSearch() {
168      root.classList.remove('is-open');
169      btn.setAttribute('aria-expanded', 'false');
170      setToggleLabel(false);
171      if (panel) {
172        panel.inert = true;
173      }
174      input.setAttribute('aria-expanded', 'false');
175      if (backdrop) {
176        backdrop.hidden = true;
177      }
178      resultsEl.classList.add('d-none');
179      resultsEl.innerHTML = '';
180      activeIndex = -1;
181      setLoading(false);
182      if (abortController) {
183        abortController.abort();
184        abortController = null;
185      }
186    }
187
188    function toggleSearch() {
189      if (isOpen()) {
190        closeSearch();
191      } else {
192        openSearch();
193      }
194    }
195
196    function setLoading(isLoading) {
197      root.classList.toggle('is-loading', isLoading);
198      resultsEl.setAttribute('aria-busy', isLoading ? 'true' : 'false');
199    }
200
201    function renderSkeletons(list) {
202      var loading = document.createElement('div');
203      loading.id = 'headerSearchLoading';
204      loading.className = 'header-search-loading';
205      loading.setAttribute('role', 'status');
206      loading.setAttribute('aria-live', 'polite');
207      var sr = document.createElement('span');
208      sr.className = 'visually-hidden';
209      sr.textContent = labels.loading;
210      loading.appendChild(sr);
211      var i;
212      for (i = 0; i < 3; i += 1) {
213        var row = document.createElement('div');
214        row.className = 'header-search-skeleton';
215        row.setAttribute('aria-hidden', 'true');
216        var titleBar = document.createElement('span');
217        titleBar.className = 'header-search-skeleton-line header-search-skeleton-title';
218        var metaBar = document.createElement('span');
219        metaBar.className = 'header-search-skeleton-line header-search-skeleton-meta';
220        row.appendChild(titleBar);
221        row.appendChild(metaBar);
222        loading.appendChild(row);
223      }
224      list.appendChild(loading);
225    }
226
227    function renderShell(query, parsed, urls, isLoading) {
228      var createHref = (urls && urls.createUrl) || buildCreateUrl(query, parsed);
229      var list = document.createElement('div');
230      list.className = 'header-search-result-list';
231      list.id = 'headerSearchResultList';
232
233      var empty = document.createElement('p');
234      empty.id = 'headerSearchEmpty';
235      empty.className = 'header-search-empty d-none';
236      empty.textContent = labels.noResults;
237
238      var actions = document.createElement('div');
239      actions.className = 'header-search-actions';
240
241      var createLink = document.createElement('a');
242      createLink.id = 'headerSearchCreateBtn';
243      createLink.href = createHref;
244      createLink.className = 'header-search-action header-search-action-create';
245      createLink.setAttribute('data-search-selectable', '1');
246      createLink.setAttribute('role', 'option');
247      createLink.setAttribute('aria-selected', 'false');
248      createLink.innerHTML = '<span class="header-search-action-main">'
249        + (labels.createIcon
250          ? '<i class="' + escapeHtml(labels.createIcon) + '" aria-hidden="true"></i>'
251          : '')
252        + '<span class="header-search-action-title">' + escapeHtml(labels.create) + '</span>'
253        + '</span>';
254
255      actions.appendChild(createLink);
256
257      resultsEl.innerHTML = '';
258      resultsEl.appendChild(list);
259      if (isLoading) {
260        renderSkeletons(list);
261      }
262      resultsEl.appendChild(empty);
263      resultsEl.appendChild(actions);
264      resultsEl.classList.remove('d-none');
265      input.setAttribute('aria-expanded', 'true');
266      activeIndex = -1;
267      setLoading(!!isLoading);
268    }
269
270    function appendMeta(meta, item) {
271      var parts = [];
272      if (item.course) {
273        parts.push(item.course);
274      }
275      if (item.class) {
276        parts.push(labels.classLabel + ' ' + item.class);
277      }
278      parts.forEach(function (text, index) {
279        if (index > 0) {
280          var sep = document.createElement('span');
281          sep.className = 'header-search-item-meta-sep';
282          sep.setAttribute('aria-hidden', 'true');
283          sep.textContent = '·';
284          meta.appendChild(sep);
285        }
286        var piece = document.createElement('span');
287        piece.textContent = text;
288        meta.appendChild(piece);
289      });
290      if (!item.typeLabel && !(item.typeIcon && (item.typeIcon.src || item.typeIcon.iconClass))) {
291        return;
292      }
293      if (parts.length > 0) {
294        var typeSep = document.createElement('span');
295        typeSep.className = 'header-search-item-meta-sep';
296        typeSep.setAttribute('aria-hidden', 'true');
297        typeSep.textContent = '·';
298        meta.appendChild(typeSep);
299      }
300      var typeWrap = document.createElement('span');
301      typeWrap.className = 'header-search-item-type';
302      var icon = item.typeIcon || {};
303      if (icon.src) {
304        var img = document.createElement('img');
305        img.src = icon.src;
306        img.alt = '';
307        img.width = 16;
308        img.height = 16;
309        img.className = 'header-search-type-icon';
310        img.setAttribute('data-testid', 'header-search-type-icon');
311        typeWrap.appendChild(img);
312      } else if (icon.iconClass) {
313        var iconEl = document.createElement('i');
314        iconEl.className = icon.iconClass + ' header-search-type-icon';
315        iconEl.setAttribute('aria-hidden', 'true');
316        iconEl.setAttribute('data-testid', 'header-search-type-icon');
317        typeWrap.appendChild(iconEl);
318      }
319      if (item.typeLabel) {
320        var typeLabel = document.createElement('span');
321        typeLabel.textContent = item.typeLabel;
322        typeWrap.appendChild(typeLabel);
323      }
324      meta.appendChild(typeWrap);
325    }
326
327    function renderResults(query, data) {
328      renderShell(query, data.parsed, {
329        createUrl: data.createUrl,
330      }, false);
331      var list = document.getElementById('headerSearchResultList');
332      var empty = document.getElementById('headerSearchEmpty');
333      var items = Array.isArray(data.results) ? data.results : [];
334      if (!list) {
335        return;
336      }
337      if (items.length === 0) {
338        if (empty) {
339          empty.classList.remove('d-none');
340        }
341        return;
342      }
343      items.forEach(function (item, index) {
344        var link = document.createElement('a');
345        link.href = item.url || '#';
346        link.id = 'headerSearchResult-' + index;
347        link.className = 'header-search-item';
348        link.setAttribute('data-testid', 'header-search-result');
349        link.setAttribute('data-search-selectable', '1');
350        link.setAttribute('role', 'option');
351        link.setAttribute('aria-selected', 'false');
352
353        var title = document.createElement('span');
354        title.className = 'header-search-item-title';
355        title.innerHTML = highlight(item.title || '', query);
356
357        var meta = document.createElement('span');
358        meta.className = 'header-search-item-meta';
359        appendMeta(meta, item);
360
361        link.appendChild(title);
362        link.appendChild(meta);
363        list.appendChild(link);
364      });
365    }
366
367    function runSearch(query) {
368      latestQuery = query;
369      trackHeaderSearch('search');
370      if (abortController) {
371        abortController.abort();
372      }
373      abortController = new AbortController();
374      fetch('fetchGallerySearch.php?q=' + encodeURIComponent(query), {
375        signal: abortController.signal,
376        headers: { 'Accept': 'application/json' },
377      }).then(function (res) {
378        return res.json();
379      }).then(function (data) {
380        if (query !== latestQuery || !isOpen()) {
381          return;
382        }
383        renderResults(query, data || {});
384      }).catch(function (err) {
385        if (err && err.name === 'AbortError') {
386          return;
387        }
388        if (query !== latestQuery || !isOpen()) {
389          return;
390        }
391        renderShell(query);
392        setLoading(false);
393        var empty = document.getElementById('headerSearchEmpty');
394        if (empty) {
395          empty.classList.remove('d-none');
396        }
397      });
398    }
399
400    var debouncedSearch = debounce(function (query) {
401      runSearch(query);
402    }, DEBOUNCE_MS);
403
404    function onInput() {
405      var query = input.value.trim();
406      if (clearBtn) {
407        clearBtn.classList.toggle('d-none', query === '');
408      }
409      if (query.length < MIN_CHARS) {
410        resultsEl.classList.add('d-none');
411        resultsEl.innerHTML = '';
412        input.setAttribute('aria-expanded', 'false');
413        setLoading(false);
414        if (abortController) {
415          abortController.abort();
416          abortController = null;
417        }
418        return;
419      }
420      renderShell(query, null, null, true);
421      debouncedSearch(query);
422    }
423
424    resultsEl.addEventListener('click', function (event) {
425      if (event.target.closest('#headerSearchCreateBtn')) {
426        trackHeaderSearch('create');
427        return;
428      }
429      if (event.target.closest('[data-testid="header-search-result"]')) {
430        trackHeaderSearch('open-result');
431      }
432    });
433
434    btn.addEventListener('click', function (event) {
435      event.preventDefault();
436      event.stopPropagation();
437      toggleSearch();
438    });
439
440    if (clearBtn) {
441      clearBtn.addEventListener('click', function (event) {
442        event.preventDefault();
443        input.value = '';
444        onInput();
445        input.focus();
446      });
447    }
448
449    if (backdrop) {
450      backdrop.addEventListener('click', function () {
451        closeSearch();
452      });
453    }
454
455    input.addEventListener('input', onInput);
456
457    input.addEventListener('keydown', function (event) {
458      if (event.key === 'Escape') {
459        event.preventDefault();
460        closeSearch();
461        btn.focus();
462        return;
463      }
464      var items = selectableItems();
465      if (event.key === 'ArrowDown') {
466        event.preventDefault();
467        if (!items.length) {
468          return;
469        }
470        setActive(activeIndex + 1);
471        return;
472      }
473      if (event.key === 'ArrowUp') {
474        event.preventDefault();
475        if (!items.length) {
476          return;
477        }
478        setActive(activeIndex - 1);
479        return;
480      }
481      if (event.key === 'Enter') {
482        var query = input.value.trim();
483        if (query.length < MIN_CHARS) {
484          return;
485        }
486        event.preventDefault();
487        if (activeIndex >= 0 && items[activeIndex]) {
488          items[activeIndex].click();
489          return;
490        }
491        if (items.length) {
492          items[0].click();
493          return;
494        }
495        trackHeaderSearch('create');
496        window.location.href = buildCreateUrl(query);
497      }
498    });
499
500    document.addEventListener('click', function (event) {
501      if (!isOpen()) {
502        return;
503      }
504      if (root.contains(event.target)) {
505        return;
506      }
507      closeSearch();
508    });
509
510    document.addEventListener('keydown', function (event) {
511      if (event.key === 'Escape' && isOpen()) {
512        event.preventDefault();
513        closeSearch();
514        btn.focus();
515      }
516    }, true);
517  }
518
519  document.addEventListener('DOMContentLoaded', initHeaderSearch);
520})();

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.