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.