PageSourceSearch

https://cividata.org/inc/script.js?v=1790166873

js cividata.org collected 2026-10-02 16:43:24 UTC 16,508 bytes, 461 lines download raw bytes

1/**
2 * Global JavaScript for Cividata
3 * This file contains general UI logic like the global search
4 */
5
6// Escape HTML to prevent XSS
7function escapeHtml(str) {
8    if (!str) return '';
9    return String(str)
10        .replace(/&/g, '&')
11        .replace(/</g, '&lt;')
12        .replace(/>/g, '&gt;')
13        .replace(/"/g, '&quot;')
14        .replace(/'/g, '&#039;');
15}
16
17// Helper to initialize Bootstrap Tooltips
18function initializeTooltips() {
19    var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
20    tooltipTriggerList.forEach(function (tooltipTriggerEl) {
21        // Only initialize if not already initialized
22        if (!bootstrap.Tooltip.getInstance(tooltipTriggerEl)) {
23            new bootstrap.Tooltip(tooltipTriggerEl);
24        }
25    });
26}
27
28document.addEventListener('DOMContentLoaded', initializeTooltips);
29
30// ---------------------------------------
31// Feature 5: Mobile Tabellen – Jahres-Spalte togglen (LocalStorage)
32// ---------------------------------------
33(function () {
34    const STORAGE_KEY = 'cividata_show_year';
35
36    function isEnabled() {
37        try { return window.localStorage.getItem(STORAGE_KEY) === '1'; }
38        catch (e) { return false; }
39    }
40
41    function setEnabled(enabled) {
42        document.body.classList.toggle('show-year', !!enabled);
43        try { window.localStorage.setItem(STORAGE_KEY, enabled ? '1' : '0'); }
44        catch (e) { /* ignore */ }
45        document.querySelectorAll('.js-year-toggle').forEach((btn) => {
46            btn.setAttribute('aria-pressed', enabled ? 'true' : 'false');
47            btn.textContent = enabled ? btn.getAttribute('data-label-on') : btn.getAttribute('data-label-off');
48        });
49    }
50
51    function getLabels() {
52        const body = document.body;
53        return {
54            on: body.getAttribute('data-year-hide') || 'Hide year',
55            off: body.getAttribute('data-year-show') || 'Show year'
56        };
57    }
58
59    document.addEventListener('DOMContentLoaded', function () {
60        const tables = Array.from(document.querySelectorAll('.table-responsive'))
61            .filter((wrap) => wrap.querySelector('th.year-column, td.year-column'));
62
63        if (tables.length === 0) return;
64
65        const labels = getLabels();
66        tables.forEach((wrap) => {
67            // Only add once
68            if (wrap.previousElementSibling && wrap.previousElementSibling.classList.contains('table-mobile-tools')) return;
69
70            const tools = document.createElement('div');
71            tools.className = 'table-mobile-tools d-flex d-md-none';
72
73            const btn = document.createElement('button');
74            btn.type = 'button';
75            btn.className = 'btn btn-outline-secondary btn-sm js-year-toggle';
76            btn.setAttribute('aria-pressed', 'false');
77            btn.setAttribute('data-label-on', labels.on);
78            btn.setAttribute('data-label-off', labels.off);
79            btn.textContent = labels.off;
80            btn.addEventListener('click', () => setEnabled(!document.body.classList.contains('show-year')));
81
82            tools.appendChild(btn);
83            wrap.parentElement.insertBefore(tools, wrap);
84        });
85
86        // Apply saved state
87        setEnabled(isEnabled());
88    });
89})();
90
91// ---------------------------------------
92// Feature 6: Global Search – Autocomplete mit Keyboard + ARIA
93// ---------------------------------------
94(function () {
95    const searchInput = document.getElementById('globalSearchInput');
96    const resultsContainer = document.getElementById('globalSearchResults');
97    const searchForm = document.getElementById('globalSearchForm');
98
99    if (!searchInput || !resultsContainer) return;
100
101    let globalSearchTimeout = null;
102    let items = [];
103    let activeIndex = -1;
104
105    function setExpanded(expanded) {
106        searchInput.setAttribute('aria-expanded', expanded ? 'true' : 'false');
107    }
108
109    function closeList() {
110        items = [];
111        activeIndex = -1;
112        searchInput.removeAttribute('aria-activedescendant');
113        resultsContainer.innerHTML = '';
114        resultsContainer.classList.remove('show');
115        setExpanded(false);
116    }
117
118    function openList() {
119        resultsContainer.classList.add('show');
120        setExpanded(true);
121    }
122
123    function setActive(idx) {
124        activeIndex = idx;
125        const options = Array.from(resultsContainer.querySelectorAll('[role="option"]'));
126        options.forEach((el, i) => {
127            const isActive = i === activeIndex;
128            el.classList.toggle('active', isActive);
129            el.setAttribute('aria-selected', isActive ? 'true' : 'false');
130            if (isActive) searchInput.setAttribute('aria-activedescendant', el.
130id);
131        });
132    }
133
134    function selectItem(item) {
135        if (!item) return;
136        if (window.CividataModal && typeof window.CividataModal.open === 'function') {
137            window.CividataModal.open(item.qid, item.label);
138        }
139        searchInput.value = '';
140        closeList();
141    }
142
143    function renderResults(nextItems) {
144        items = Array.isArray(nextItems) ? nextItems : [];
145        activeIndex = -1;
146        resultsContainer.innerHTML = '';
147
148        if (items.length === 0) {
149            closeList();
150            return;
151        }
152
153        items.forEach((item, idx) => {
154            const btn = document.createElement('button');
155            btn.type = 'button';
156            btn.className = 'list-group-item list-group-item-action border-0 py-2';
157            btn.id = `globalSearchOption-${idx}`;
158            btn.setAttribute('role', 'option');
159            btn.setAttribute('aria-selected', 'false');
160
161            const flag = item.country_flag ? ` ${escapeHtml(item.country_flag)}` : '';
162            btn.innerHTML = `<div class="global-search-item-title"><strong>${escapeHtml(item.label)}${flag}</strong></div>`;
163
164            btn.addEventListener('click', () => selectItem(item));
165            resultsContainer.appendChild(btn);
166        });
167
168        openList();
169    }
170
171    searchInput.addEventListener('input', function () {
172        const q = this.value.trim();
173        if (globalSearchTimeout) clearTimeout(globalSearchTimeout);
174
175        if (q.length < 2) {
176            closeList();
177            return;
178        }
179
180        globalSearchTimeout = setTimeout(async function () {
181            const lang = document.documentElement.lang || 'de';
182            try {
183                const response = await fetch('/inc/global_search.php?q=' + encodeURIComponent(q) + '&lang=' + lang);
184                const data = await response.json();
185                renderResults(data.items || []);
186            } catch (e) {
187                closeList();
188            }
189        }, 250);
190    });
191
192    searchInput.addEventListener('keydown', function (e) {
193        const isOpen = resultsContainer.classList.contains('show');
194        const optionEls = Array.from(resultsContainer.querySelectorAll('[role="option"]'));
195
196        if (e.key === 'ArrowDown') {
197            if (!isOpen && optionEls.length > 0) openList();
198            if (optionEls.length > 0) {
199                e.preventDefault();
200                const next = Math.min(activeIndex + 1, optionEls.length - 1);
201                setActive(next);
202            }
203        } else if (e.key === 'ArrowUp') {
204            if (optionEls.length > 0) {
205                e.preventDefault();
206                const next = Math.max(activeIndex - 1, 0);
207                setActive(next);
208            }
209        } else if (e.key === 'Enter') {
210            if (isOpen && activeIndex >= 0 && items[activeIndex]) {
211                e.preventDefault();
212                selectItem(items[activeIndex]);
213            }
214        } else if (e.key === 'Escape') {
215            if (isOpen) {
216                e.preventDefault();
217                closeList();
218            }
219        }
220    });
221
222    document.addEventListener('click', function (e) {
223        if (e.target === searchInput) return;
224        if (resultsContainer.contains(e.target)) return;
225        closeList();
226    });
227
228    if (searchForm) searchForm.addEventListener('submit', (e) => e.preventDefault());
229})();
230
231// ---------------------------------------
232// Feature: List Table with Tabs and Sortable Columns
233// ---------------------------------------
234(function () {
235    // Check if we're on a list page with table configuration
236    if (typeof listTableConfig === 'undefined') return;
237
238    const config = listTableConfig;
239    let currentProperty = config.currentProperty || (config.properties ? config.properties[0] : null);
240    let currentSort = { field: 'value', direction: 'desc' };
241    let currentPage = 1;
242    let searchTerm = '';
243    let totalCount = Number(config.initialTotalCount || 0);
244    let isLoading = false;
245
246    // DOM elements
247    const tableBody = document.getElementById('tableBody');
248    const orgCountSpan = document.getElementById('orgCount');
249    const valueHeader = document.getElementById('valueHeader');
250    const loadMoreBtn = document.getElementById('loadMoreBtn');
251    const searchInput = document.getElementById('list_search');
252    const tabs = document.querySelectorAll('#propertyTabs .nav-link');
253    const sortableHeaders = document.querySelectorAll('#listTable th.sortable');
254    const propertyDropdown = document.getElementById('propertyDropdown');
255
256    if (!tableBody || !currentProperty || !config.ajaxUrl) return;
257
258    function reinitTooltips() {
259        if (typeof bootstrap === 'undefined' || !bootstrap.Tooltip) return;
260        const tooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]');
261        tooltips.forEach((el) => {
262            if (!bootstrap.Tooltip.getInstance(el)) {
263                new bootstrap.Tooltip(el);
264            }
265        });
266    }
267
268    function updateHeader() {
269        const label = config.tableLabels[currentProperty] || currentProperty;
270        if (valueHeader) valueHeader.textContent = label;
271        if (orgCountSpan) orgCountSpan.textContent = `(${totalCount})`;
272    }
273
274    function updateLoadMoreButton(lastHtmlChunk) {
275        if (!loadMoreBtn) return;
276        const hasMore = (currentPage * config.itemsPerPage) < totalCount && (lastHtmlChunk || '').trim() !== '';
277        loadMoreBtn.classList.toggle('is-hidden', !hasMore);
278    }
279
280    function updateSortIndicators() {
281        sortableHeaders.forEach((th) => {
282            const field = th.getAttribute('data-sort');
283            const icon = th.querySelector('.sort-icon');
284
285            if (field === currentSort.field) {
286                th.classList.add('active');
287                th.setAttribute('data-direction', currentSort.direction);
288                if (icon) {
289                    icon.className = currentSort.direction === 'asc'
290                        ? 'fas fa-sort-up sort-icon'
291                        : 'fas fa-sort-down sort-icon';
292                }
293            } else {
294                th.classList.remove('active');
295                th.removeAttribute('data-direction');
296                if (icon) {
297                    icon.className = 'fas fa-sort sort-icon';
298                }
299            }
300        });
301    }
302
303    async function loadTablePage(append) {
304        if (isLoading) return;
305        isLoading = true;
306        if (loadMoreBtn) loadMoreBtn.disabled = true;
307
308        const params = new URLSearchParams();
309        params.set('ajax', 'true');
310        params.set('table', currentProperty);
311        params.set('search', searchTerm);
312        params.set(`${currentProperty}_page`, String(currentPage));
313        params.set('sort_field', currentSort.field);
314        params.set('sort_direction', currentSort.direction);
315        params.set('csrf_token', config.csrfToken || '');
316
317        try {
318            const response = await fetch(config.ajaxUrl, {
319                method: 'POST',
320                headers: {
321                    'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
322                },
323                body: params.toString()
324            });
325
326            if (!response.ok) {
327                throw new Error(`HTTP ${response.status}`);
328            }
329
330            const payload = await response.json();
331            const htmlKey = `${currentProperty}_html`;
332            const html = payload[htmlKey] || '';
333            totalCount = Number(payload.total_count || 0);
334
335            if (append) {
336                tableBody.insertAdjacentHTML('beforeend', html);
337            } else {
338                tableBody.innerHTML = html || `<tr><td colspan="5" class="text-center text-muted py-4">${escapeHtml(config.uiLabels.no_data || 'No data available')}</td></tr>`;
339            }
340
341            updateHeader();
342            updateLoadMoreButton(html);
343            reinitTooltips();
344        } catch (error) {
345            if (!append) {
346                tableBody.innerHTML = `<tr><td colspan="5" class="text-center text-muted py-4">${escapeHtml(config.uiLabels.no_data || 'No data available')}</td></tr>`;
347            }
348            updateLoadMoreButton('');
349        } finally {
350            isLoading = false;
351            if (loadMoreBtn) loadMoreBtn.disabled = false;
352        }
353    }
354
355    // Event delegation: single listener handles all row/button clicks
356    tableBody.addEventListener('click', function(e) {
357        // Ignore clicks on external links
358        if (e.target.tagName === 'A' || e.target.closest('a')) return;
359
360        const btn = e.target.closest('.org-open-modal');
361        if (!btn) return;
362
363        const qid = btn.getAttribute('data-qid');
364        const label = (btn.getAttribute('data-label') || btn.textContent || '').trim();
365        if (qid && window.CividataModal && typeof window.CividataModal.open === 'function') {
366            window.CividataModal.open(qid, label);
367        }
368    });
369
370    // Switch to a property tab
371    function switchTab(property) {
372        if (!config.properties || !config.properties.includes(property)) return;
373        currentProperty = property;
374        currentPage = 1;
375
376        // Update active tab (desktop)
377        tabs.forEach((tab) => {
378            const tabProp = tab.getAttribute('data-property');
379            if (tabProp === property) {
380                tab.classList.add('active');
381                tab.setAttribute('aria-selected', 'true');
382            } else {
383                tab.classList.remove('active');
384                tab.setAttribute('aria-selected', 'false');
385            }
386        });
387        
388        // Update dropdown (mobile)
389        if (propertyDropdown) propertyDropdown.value = property;
390        updateHeader();
391        loadTablePage(false);
392    }
393
394    // Event: Tab click (desktop)
395    tabs.forEach((tab) => {
396        tab.addEventListener('click', function () {
397            const property = this.getAttribute('data-property');
398            switchTab(property);
399        });
400    });
401
402    // Event: Dropdown change (mobile)
403    if (propertyDropdown) {
404        propertyDropdown.addEventListener('change', function () {
405            switchTab(this.value);
406        });
407    }
408
409    // Event: Sortable header click
410    sortableHeaders.forEach((th) => {
411        th.addEventListener('click', function () {
412            const field = this.getAttribute('data-sort');
413
414            if (currentSort.field === field) {
415                currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
416            } else {
417                currentSort.field = field;
418                currentSort.direction = field === 'name' ? 'asc' : 'desc';
419            }
420
421            currentPage = 1;
422            updateSortIndicators();
423            loadTablePage(false);
424        });
425
426        // Keyboard accessibility
427        th.addEventListener('keydown', function (e) {
428            if (e.key === 'Enter' || e.key === ' ') {
429                e.preventDefault();
430                this.click();
431            }
432        });
433    });
434
435    // Event: Search input
436    if (searchInput) {
437        let searchTimeout = null;
438        searchInput.addEventListener('input', function () {
439            if (searchTimeout) clearTimeout(searchTimeout);
440            searchTimeout = setTimeout(() => {
441                searchTerm = this.value.trim();
442                currentPage = 1;
443                loadTablePage(false);
444            }, 200);
445        });
446    }
447
448    // Event: Load more button
449    if (loadMoreBtn) {
450        loadMoreBtn.addEventListener('click', function () {
451            currentPage += 1;
452            loadTablePage(true);
453        });
454    }
455
456    // Initial setup (table body is already SSR-rendered)
457    updateSortIndicators();
458    updateHeader();
459    updateLoadMoreButton('initial');
460    reinitTooltips();
461})();

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.