PageSourceSearch

https://sw.officialsite.com/js/site.js?v=19

js officialsite.com collected 2026-09-26 14:43:38 UTC 20,231 bytes, 484 lines download raw bytes

1// Static SSR client-side interactions
2(function () {
3    'use strict';
4
5    // Track currently expanded listing element
6    let expandedListingElement = null;
7
8    // Initialize when DOM is ready
9    document.addEventListener('DOMContentLoaded', function () {
10        init();
11
12        // Watch for DOM changes (Blazor enhanced navigation replaces page content)
13        // MutationObserver is more reliable than Blazor events across .NET versions
14        new MutationObserver(function () {
15            var lazy = document.querySelector('.youtube-lazy:not([data-resolved])');
16            if (lazy) {
17                var listingItem = lazy.closest('.listing-item');
18                if (listingItem && (listingItem.classList.contains('expanded') || listingItem.classList.contains('nowExpanded'))) {
19                    expandedListingElement = listingItem;
20                    resolveYouTubeLazy(listingItem);
21                }
22            }
23        }).observe(document.documentElement, { childList: true, subtree: true });
24    });
25
26    // Hide spinner on back/forward navigation (bfcache)
27    window.addEventListener('pageshow', function (e) {
28        if (e.persisted) {
29            hideLoadingSpinner();
30        }
31    });
32
33    function init() {
34        // Hide loading spinner when page loads
35        hideLoadingSpinner();
36
37        initRegionToggle();
38        initFilterInputs();
39        initRegionFilter();
40        initShareButtons();
41        initPageLoadTimeCookie();
42        initGlobalSearch();
43        initMobileCollapseState();
44        initLinkSpinner();
45
46        // Check for pre-expanded listing (from URL/server — SSR uses 'expanded' class)
47        var preExpanded = document.querySelector('.listing-item.expanded') || document.querySelector('.listing-item.nowExpanded');
48        if (preExpanded) {
49            expandedListingElement = preExpanded;
50            // Resolve YouTube lazy elements in pre-expanded listings
51            resolveYouTubeLazy(preExpanded);
52        }
53    }
54
55    // Show loading spinner
56    function showLoadingSpinner() {
57        var spinner = document.getElementById('page-loading-spinner');
58        if (spinner) {
59            spinner.classList.add('active');
60        }
61    }
62
63    // Hide loading spinner
64    function hideLoadingSpinner() {
65        var spinner = document.getElementById('page-loading-spinner');
66        if (spinner) {
67            spinner.classList.remove('active');
68        }
69    }
70
71    // Show spinner on navigation link clicks
72    function initLinkSpinner() {
73        document.addEventListener('click', function (e) {
74            var link = e.target.closest('a');
75            if (!link) return;
76
77            var href = link.getAttribute('href');
78            // Skip if no href, hash-only, javascript:, or external link
79            if (!href || href.startsWith('#') || href.startsWith('javascript:')) return;
80            // Skip if opens in new tab
81            if (link.target === '_blank') return;
82            // Skip if it's a listing name click (expand, not navigate)
83            if (link.classList.contains('listing-name-clickable')) return;
84
85            // Show spinner for navigation links
86            showLoadingSpinner();
87        });
88    }
89
90    // Set correct toggle button states based on screen size
91    function initMobileCollapseState() {
92        var isDesktop = window.innerWidth >= 960;
93        var regionToggle = document.querySelector('.region-toggle-btn');
94        var regionList = document.querySelector('.child-regions-list');
95        var categoryList = document.querySelector('.category-list');
96        var searchWrapper = document.querySelector('.category-search-wrapper');
97
98        if (isDesktop) {
99            // On desktop, show both sub-regions and categories as expanded
100            if (regionToggle) {
101                regionToggle.classList.remove('collapsed');
102                regionToggle.classList.add('expanded');
103            }
104            if (regionList) {
105                regionList.classList.remove('collapsed');
106            }
107            if (categoryList) {
108                categoryList.classList.remove('collapsed');
109            }
110            if (searchWrapper) {
111                searchWrapper.style.display = '';
112            }
113        } else {
114            // On mobile, collapse both sub-regions and categories together
115            if (regionToggle) {
116                regionToggle.classList.add('collapsed');
117                regionToggle.classList.remove('expanded');
118            }
119            if (regionList) {
120                regionList.classList.add('collapsed');
121            }
122            if (categoryList) {
123                categoryList.classList.add('collapsed');
124            }
125            if (searchWrapper) {
126                searchWrapper.style.display = 'none';
127            }
128        }
129    }
130
131    // Set pageLoadTime cookie from data attribute (for listing NUMBER clicks that navigate)
132    function initPageLoadTimeCookie() {
133        const container = document.querySelector('.listings-container');
134        if (container && container.dataset.pageLoadTime) {
135            var loadTime = container.dataset.pageLoadTime;
136            document.cookie = 'pageLoadTime=' + loadTime + '; path=/; max-age=300; SameSite=Lax';
137            console.log('Set pageLoadTime cookie:', loadTime);
138        }
139    }
140
141    // YouTube lazy resolution — fetches latest video via RSS feed on listing expand
142    function resolveYouTubeLazy(listingItem) {
143        var lazyEl = listingItem.querySelector('.youtube-lazy');
144        if (!lazyEl || lazyEl.dataset.resolved) return;
145        lazyEl.dataset.resolved = '1';
146
147        // Use channelId (fast, 1 call) or fall back to url (slower, 2 calls)
148        var channelId = lazyEl.dataset.channelId;
149        var url = channelId
150            ? '/api/youtube/resolve?channelId=' + encodeURIComponent(channelId)
151            : '/api/youtube/resolve?url=' + encodeURIComponent(lazyEl.dataset.youtubeUrl);
152        fetch(url)
153            .then(function (r) { return r.ok ? r.json() : Promise.reject('not found'); })
154            .then(function (data) {
155                lazyEl.classList.add('youtube-resolved');
156                lazyEl.innerHTML = '<iframe width="560" height="315" src="https://www.youtube-nocookie.com/embed/' + data.videoId + '" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>';
157            })
158            .catch(function () {
159                lazyEl.innerHTML = '<div style="padding:20px;text-align:center;color:#999;">Video unavailable</div>';
160            });
161    }
162
163    // YouTube button + listing expansion — event delegation (survives Blazor enhanced navigation)
164    document.addEventListener('click', function (e) {
165        // YouTube button click → trigger listing expansion
166        var ytBtn = e.target.closest('.youtube-expand-btn');
167        if (ytBtn) {
168            e.preventDefault();
169            e.stopPropagation();
170            var listingItem = ytBtn.closest('.listing-item');
171            if (listingItem) {
172                var clickable = listingItem.querySelector('.listing-name-clickable');
173                if (clickable) clickable.click();
174            }
175            return;
176        }
177
178        // Listing name click → expand/collapse
179        var clickable = e.target.closest('.listing-name-clickable');
180        if (clickable) {
181            e.preventDefault();
182            var listingItem = clickable.closest('.listing-item');
183            if (!listingItem) return;
184
185            // Collapse previous expanded listing
186            if (expandedListingElement && expandedListingElement !== listingItem) {
187                expandedListingElement.classList.remove('nowExpanded');
188                expandedListingElement.classList.add('notExpanded');
189            }
190
191            // Toggle current listing
192            var isExpanded = listingItem.classList.contains('nowExpanded');
193            if (isExpanded) {
194                listingItem.classList.remove('nowExpanded');
195                listingItem.classList.add('notExpanded');
196                expandedListingElement = null;
197            } else {
198                listingItem.classList.remove('notExpanded');
199                listingItem.classList.add('nowExpanded');
200                expandedListingElement = listingItem;
201                resolveYouTubeLazy(listingItem);
202            }
203        }
204    });
205
206
207    // Move a listing to the top of its sub-region and renumber (not used with static SSR navigation)
208    function moveListingToTop(listingItem) {
209        // Get sub-region ID from class (e.g., "subregion-123")
210        const subregionMatch = listingItem.className.match(/subregion-(\d+)/);
211        if (!subregionMatch) return;
212
213        const subregionId = subregionMatch[1];
214        const container = document.querySelector('.listings-container');
215        if (!container) return;
216
217        const subregionListings = Array.from(container.querySelectorAll('.listing-item.subregion-' + subregionId));
218        if (subregionListings.length <= 1) return;
219
220        const firstInSubregion = subregionListings[0];
221        if (firstInSubregion === listingItem) return;
222
223        // Move the clicked listing before the first one in its sub-region
224        firstInSubregion.parentNode.insertBefore(listingItem, firstInSubregion);
225
226        // Renumber all listings in this sub-region
227        const updatedListings = Array.from(container.querySelectorAll('.listing-item.subregion-' + subregionId));
228        updatedListings.forEach(function (item, index) {
229            const numberEl = item.querySelector('.listing-number');
230            if (numberEl) {
231                numberEl.textContent = (index + 1).toString();
232            }
233        });
234    }
235
236    // Region toggle - controls both sub-regions and categories together
237    function initRegionToggle() {
238        const toggleBtn = document.querySelector('.region-toggle-btn');
239        if (toggleBtn) {
240            toggleBtn.addEventListener('click', function () {
241                const regionList = document.querySelector('.child-regions-list');
242                const categoryList = document.querySelector('.category-list');
243                const searchWrapper = document.querySelector('.category-search-wrapper');
244                if (regionList) {
245                    const isCollapsed = regionList.classList.toggle('collapsed');
246                    regionList.classList.add('user-collapsed');
247                    // Toggle button class to show correct icon
248                    this.classList.toggle('expanded', !isCollapsed);
249                    this.classList.toggle('collapsed', isCollapsed);
250                    // Also toggle categories in sync
251                    if (categoryList) {
252                        categoryList.classList.toggle('collapsed', isCollapsed);
253                        categoryList.classList.add('user-collapsed');
254                    }
255                    if (searchWrapper) {
256                        searchWrapper.style.display = isCollapsed ? 'none' : '';
257                    }
258                }
259            });
260        }
261    }
262
263    // Filter text inputs (client-side filtering)
264    function initFilterInputs() {
265        // Listing filter
266        const listingFilter = document.querySelector('.listing-filter-input');
267        if (listingFilter) {
268            listingFilter.addEventListener('input', function () {
269                const searchText = this.value.toLowerCase();
270                document.querySelectorAll('.listing-item').forEach(function (item) {
271                    const name = item.querySelector('.listing-name')?.textContent?.toLowerCase() || '';
272                    const address = item.querySelector('.listing-address')?.textContent?.toLowerCase() || '';
273                    if (searchText === '' || name.includes(searchText) || address.includes(searchText)) {
274                        item.style.removeProperty('display');
275                    } else {
276                        item.style.display = 'none';
277                    }
278                });
279            });
280        }
281
282        // Category filter
283        const categoryFilter = document.querySelector('.category-search-input');
284        if (categoryFilter) {
285            categoryFilter.addEventListener('input', function () {
286                const searchText = this.value.toLowerCase();
287                document.querySelectorAll('.category-list .category-item').forEach(function (item) {
288                    const name = item.textContent?.toLowerCase() || '';
289                    if (searchText === '' || name.includes(searchText)) {
290                        item.style.removeProperty('display');
291                    } else {
292                        item.style.display = 'none';
293                    }
294                });
295            });
296        }
297    }
298
299    // Region filter buttons
300    function initRegionFilter() {
301        document.querySelectorAll('.region-filter-btn').forEach(function (btn) {
302            btn.addEventListener('click', function () {
303                const regionId = this.dataset.regionId;
304                const isActive = this.classList.contains('filter-active');
305
306                // Reset all filter buttons to inactive
307                document.querySelectorAll('.region-filter-btn').forEach(function (b) {
308                    b.classList.remove('filter-active');
309                    b.classList.add('filter-inactive');
310                });
311
312                if (isActive) {
313                    // Show all listings
314                    document.querySelectorAll('.listing-item').forEach(function (item) {
315                        item.style.display = '';
316                    });
317                } else {
318                    // Activate this filter
319                    this.classList.remove('filter-inactive');
320                    this.classList.add('filter-active');
321                    // Filter listings
322                    document.querySelectorAll('.listing-item').forEach(function (item) {
323                        if (item.classList.contains('subregion-' + regionId)) {
324                            item.style.display = '';
325                        } else {
326                            item.style.display = 'none';
327                        }
328                    });
329                }
330            });
331        });
332    }
333
334    // Share buttons
335    function initShareButtons() {
336        // Copy link buttons
337        document.querySelectorAll('.copy-link-btn').forEach(function (btn) {
338            btn.addEventListener('click', function () {
339                const url = this.dataset.url;
340                if (navigator.clipboard) {
341                    navigator.clipboard.writeText(url);
342                }
343            });
344        });
345
346        // Native share buttons
347        document.querySelectorAll('.native-share-btn').forEach(function (btn) {
348            btn.addEventListener('click', function () {
349                const url = this.dataset.url;
350                const title = this.dataset.title;
351                if (navigator.share) {
352                    navigator.share({ title: title, url: url });
353                }
354            });
355        });
356    }
357
358    // Global search with AJAX
359    function initGlobalSearch() {
360        const searchInput = document.getElementById('global-search-input');
361        const dropdown = document.getElementById('global-search-dropdown');
362        if (!searchInput || !dropdown) return;
363
364        let debounceTimer = null;
365        let currentQuery = '';
366
367        // Handle input changes with debouncing
368        searchInput.addEventListener('input', function () {
369            const query = this.value.trim();
370            currentQuery = query;
371
372            // Clear previous timer
373            if (debounceTimer) clearTimeout(debounceTimer);
374
375            // Hide dropdown if query too short
376            if (query.length < 2) {
377                dropdown.style.display = 'none';
378                dropdown.innerHTML = '';
379                return;
380            }
381
382            // Debounce the search
383            debounceTimer = setTimeout(function () {
384                fetchSuggestions(query);
385            }, 300);
386        });
387
388        // Fetch suggestions from API
389        function fetchSuggestions(query) {
390            // Parse "name, city" format
391            var parts = query.split(',');
392            var nameQuery = parts[0].trim();
393            var cityQuery = parts.length > 1 ? parts.slice(1).join(',').trim() : '';
394            var url = '/api/search/suggestions?q=' + encodeURIComponent(nameQuery) + '&count=10';
395            if (cityQuery) {
396                url += '&city=' + encodeURIComponent(cityQuery);
397            }
398            fetch(url)
399                .then(function (response) { return response.json(); })
400                .then(function (results) {
401                    // Only update if query hasn't changed
402                    if (query !== currentQuery) return;
403                    renderDropdown(results);
404                })
405                .catch(function () {
406                    dropdown.style.display = 'none';
407                });
408        }
409
410        // Render dropdown with results
411        function renderDropdown(results) {
412            if (!results || results.length === 0) {
413                dropdown.innerHTML = '<div class="search-no-results">No matches found</div>';
414                dropdown.style.display = 'block';
415                return;
416            }
417
418            let html = '';
419            results.forEach(function (item) {
420                const displayName = item.city ? item.name + ', ' + item.city : item.name;
421                const location = [item.category, item.region].filter(Boolean).join(' - ');
422                html += '<a href="' + item.url + '" class="search-result-item">' +
423                    '<div class="search-result-name">' + escapeHtml(displayName) + '</div>' +
424                    (location ? '<div class="search-result-location">' + escapeHtml(location) + '</div>' : '') +
425                    '</a>';
426            });
427            dropdown.innerHTML = html;
428            dropdown.style.display = 'block';
429        }
430
431        // Close dropdown when clicking outside
432        document.addEventListener('click', function (e) {
433            if (!searchInput.contains(e.target) && !dropdown.contains(e.target)) {
434                dropdown.style.display = 'none';
435            }
436        });
437
438        // Handle keyboard navigation
439        searchInput.addEventListener('keydown', function (e) {
440            const items = dropdown.querySelectorAll('.search-result-item');
441            const activeItem = dropdown.querySelector('.search-result-item.active');
442            let activeIndex = Array.from(items).indexOf(activeItem);
443
444            if (e.key === 'ArrowDown') {
445                e.preventDefault();
446                if (activeItem) activeItem.classList.remove('active');
447                activeIndex = (activeIndex + 1) % items.length;
448                if (items[activeIndex]) items[activeIndex].classList.add('active');
449            } else if (e.key === 'ArrowUp') {
450                e.preventDefault();
451                if (activeItem) activeItem.classList.remove('active');
452                activeIndex = activeIndex <= 0 ? items.length - 1 : activeIndex - 1;
453                if (items[activeIndex]) items[activeIndex].classList.add('active');
454            } else if (e.key === 'Enter') {
455                if (activeItem) {
456                    e.preventDefault();
457                    window.location.href = activeItem.getAttribute('href');
458                }
459            } else if (e.key === 'Escape') {
460                dropdown.style.display = 'none';
461            }
462        });
463
464        // Highlight on hover
465        dropdown.addEventListener('mouseover', function (e) {
466            const item = e.target.closest('.search-result-item');
467            if (item) {
468                dropdown.querySelectorAll('.search-result-item.active').forEach(function (el) {
469                    el.classList.remove('active');
470                });
471                item.classList.add('active');
472            }
473        });
474    }
475
476    // HTML escape helper
477    function escapeHtml(text) {
478        if (!text) return '';
479        const div = document.createElement('div');
480        div.textContent = text;
481        return div.innerHTML;
482    }
483
484})();

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.