1/** 2 * Art Show Hub - Public Show Page 3 * Handles filtering, sorting, voting, favorites, quick view, and UI interactions. 4 * Expects window.ASH to be set by the PHP template. 5 */ 6(function() { 7 'use strict'; 8 9 // âââ State âââââââââââââââââââââââââââââââââââââââââââââââââââââââ 10 var activeFilter = 'all'; 11 var activeSort = 'featured'; 12 var activeArtistId = null; 13 var searchQuery = ''; 14 var searchTimer = null; 15 // Filters panel (price / type / size) 16 var activePriceMax = null; 17 var activeType = ''; 18 var activeSize = ''; 19 var priceTimer = null; 20 // One-shot flag: the next featured sort is a full fresh shuffle (Refresh Art) 21 var refreshShufflePending = false; 22 23 // âââ DOM References ââââââââââââââââââââââââââââââââââââââââââââââ 24 var grid = document.getElementById('artwork-grid'); 25 var searchInput = document.getElementById('search-input'); 26 var resultsCount = document.getElementById('results-count'); 27 var toast = document.getElementById('toast'); 28 var toastMessage = document.getElementById('toast-message'); 29 var quickviewModal = document.getElementById('quickview-modal'); 30 var sortDropdown = document.querySelector('.sort-dropdown'); 31 var sortLabel = document.querySelector('#sort-label'); 32 var artistDropdown = document.querySelector('#artist-dropdown'); 33 var userToggle = document.getElementById('user-toggle'); 34 var userDropdown = document.getElementById('user-dropdown'); 35 36 // âââ Initialization ââââââââââââââââââââââââââââââââââââââââââââââ 37 function init() { 38 if (!window.ASH || !grid) return; 39 40 bindFilterTabs(); 41 bindSearch(); 42 bindSortDropdown(); 43 bindArtistDropdown(); 44 bindViewToggle(); 45 bindGridDelegation(); 46 bindQuickView(); 47 bindUserMenu(); 48 bindKeyboard(); 49 bindSurpriseMe(); 50 bindFiltersPanel(); 51 bindRefreshArt(); 52 initLayoutMode(); 53 54 // Browsers restore typed text into the search box on reload/back 55 // WITHOUT firing input events - adopt whatever is already there so the 56 // grid never ignores a visible query. 57 syncSearchFromInput(); 58 59 // Initial render 60 applyFiltersAndSort(); 61 62 // Same story for back/forward-cache restores. 63 window.addEventListener('pageshow', function(e) { 64 if (e.persisted) { 65 syncSearchFromInput(); 66 applyFiltersAndSort(); 67 } 68 }); 69 70 // Browsers restore form text at unpredictable times after load, with 71 // no events (Chrome's session restore can land seconds in). A one-shot 72 // check missed it, so watch continuously - one string compare per half 73 // second is free and also covers autofill and extensions. 74 if (searchInput) { 75 setInterval(function() { 76 if (searchInput.value.trim().toLowerCase() !== searchQuery) { 77 syncSearchFromInput(); 78 applyFiltersAndSort(); 79 } 80 }, 500); 81 } 82 } 83 84 function syncSearchFromInput() { 85 if (searchInput) { 86 searchQuery = searchInput.value.trim().toLowerCase(); 87 } 88 } 89 90 // âââ Layout Mode (paginated / infinite scroll) ââââââââââââââââââ 91 var layoutMode = 'classic_grid'; 92 var perPage = 24; 93 var currentPage = 1; 94 var totalVisibleCards = 0; 95 var infiniteLoading = false; 96 97 function initLayoutMode() { 98 layoutMode = window.ASH.displayLayout || 'classic_grid'; 99 perPage = window.ASH.itemsPerPage || 24; 100 101 if (layoutMode === 'paginated') { 102 buildPaginationControls(); 103 } else if (layoutMode === 'infinite_scroll') { 104 bindInfiniteScroll(); 105 } 106 } 107 108 function applyLayoutPaging() { 109 if (!grid) return; 110 // Filter visibility is the .ash-hidden class; paging uses its own 111 // class so the two never fight (inline display loses to the layout 112 // system's !important rules and must not be used for cards). 113 var visibleCards = grid.querySelectorAll('.artwork-card:not(.ash-hidden)'); 114 totalVisibleCards = visibleCards.length; 115 116 if (layoutMode === 'paginated') { 117 var totalPages = Math.ceil(totalVisibleCards / perPage); 118 if (currentPage > totalPages) currentPage = 1; 119 var start = (currentPage - 1) * perPage; 120 var end = start + perPage; 121 122 for (var i = 0; i < visibleCards.length; i++) { 123 visibleCards[i].classList.toggle('ash-paged-out', !(i >= start && i < end)); 124 } 125 updatePaginationControls(totalPages); 126 } else if (layoutMode === 'infinite_scroll') { 127 // Show only first N * perPage items 128 var showCount = currentPage * perPage; 129 for (var i = 0; i < visibleCards.length; i++) { 130 visibleCards[i].classList.toggle('ash-paged-out', i >= showCount); 131 } 132 updateInfiniteScrollState(); 133 } else { 134 // classic_grid, masonry, minimal - show all 135 for (var j = 0; j < visibleCards.length; j++) { 136 visibleCards[j].classList.remove('ash-paged-out'); 137 } 138 } 139 } 140 141 function buildPaginationControls() {
142 var container = document.querySelector('.artwork-grid-container'); 143 if (!container || document.getElementById('pagination-controls')) return; 144 145 var div = document.createElement('div'); 146 div.className = 'pagination-controls'; 147 div.id = 'pagination-controls'; 148 container.appendChild(div); 149 } 150 151 function updatePaginationControls(totalPages) { 152 var controls = document.getElementById('pagination-controls'); 153 if (!controls) return; 154 155 var html = ''; 156 html += '<button class="page-btn page-prev" ' + (currentPage <= 1 ? 'disabled' : '') + '>«</button>'; 157 for (var p = 1; p <= totalPages; p++) { 158 if (totalPages > 7 && Math.abs(p - currentPage) > 2 && p !== 1 && p !== totalPages) { 159 if (p === 2 && currentPage > 4) html += '<span class="pagination-info">...</span>'; 160 if (p === totalPages - 1 && currentPage < totalPages - 3) html += '<span class="pagination-info">...</span>'; 161 continue; 162 } 163 html += '<button class="page-btn ' + (p === currentPage ? 'active' : '') + '" data-page="' + p + '">' + p + '</button>'; 164 } 165 html += '<button class="page-btn page-next" ' + (currentPage >= totalPages ? 'disabled' : '') + '>»</button>'; 166 html += '<span class="pagination-info">Showing ' + (((currentPage-1)*perPage)+1) + '-' + Math.min(currentPage*perPage, totalVisibleCards) + ' of ' + totalVisibleCards + '</span>'; 167 168 controls.innerHTML = html; 169 170 // Bind page buttons 171 controls.querySelectorAll('.page-btn[data-page]').forEach(function(btn) { 172 btn.addEventListener('click', function() { 173 currentPage = parseInt(this.getAttribute('data-page'), 10); 174 applyLayoutPaging(); 175 window.scrollTo({ top: grid.offsetTop - 120, behavior: 'smooth' }); 176 }); 177 }); 178 var prevBtn = controls.querySelector('.page-prev'); 179 if (prevBtn) prevBtn.addEventListener('click', function() { if (currentPage > 1) { currentPage--; applyLayoutPaging(); window.scrollTo({ top: grid.offsetTop - 120, behavior: 'smooth' }); } }); 180 var nextBtn = controls.querySelector('.page-next'); 181 if (nextBtn) nextBtn.addEventListener('click', function() { if (currentPage < totalPages) { currentPage++; applyLayoutPaging(); window.scrollTo({ top: grid.offsetTop - 120, behavior: 'smooth' }); } }); 182 } 183 184 function bindInfiniteScroll() { 185 window.addEventListener('scroll', function() { 186 if (infiniteLoading) return; 187 var cards = grid.querySelectorAll('.artwork-card:not([style*="display: none"])'); 188 var allCards = grid.querySelectorAll('.artwork-card'); 189 if (cards.length >= allCards.length) return; // all shown 190 191 var scrollBottom = window.innerHeight + window.scrollY; 192 var gridBottom = grid.offsetTop + grid.offsetHeight; 193 if (scrollBottom > gridBottom - 400) { 194 currentPage++; 195 applyLayoutPaging(); 196 } 197 }); 198 } 199 200 function updateInfiniteScrollState() { 201 var shown = grid.querySelectorAll('.artwork-card:not([style*="display: none"])').length; 202 var sentinel = document.getElementById('load-more-sentinel'); 203 if (shown >= totalVisibleCards) { 204 if (sentinel) sentinel.remove(); 205 } else if (!sentinel) { 206 sentinel = document.createElement('div'); 207 sentinel.id = 'load-more-sentinel'; 208 sentinel.className = 'load-more-sentinel'; 209 sentinel.innerHTML = '<div class="load-more-spinner"></div>'; 210 grid.parentNode.appendChild(sentinel); 211 } 212 } 213 214 // âââ 1. Filter Tabs âââââââââââââââââââââââââââââââââââââââââââââ 215 function bindFilterTabs() { 216 var tabs = document.querySelectorAll('.filter-tab'); 217 for (var i = 0; i < tabs.length; i++) { 218 tabs[i].addEventListener('click', function(e) { 219 var tab = e.currentTarget; 220 var filter = tab.getAttribute('data-filter'); 221 if (!filter) return; 222 223 var allTabs = document.querySelectorAll('.filter-tab'); 224 for (var j = 0; j < allTabs.length; j++) { 225 allTabs[j].classList.remove('active'); 226 } 227 tab.classList.add('active'); 228
229 activeFilter = filter; 230 applyFiltersAndSort(); 231 }); 232 } 233 } 234 235 // âââ 2. Search ââââââââââââââââââââââââââââââââââââââââââââââââââ 236 function bindSearch() { 237 if (!searchInput) return; 238 searchInput.addEventListener('input', function() { 239 clearTimeout(searchTimer); 240 searchTimer = setTimeout(function() { 241 searchQuery = searchInput.value.trim().toLowerCase(); 242 applyFiltersAndSort(); 243 }, 300); 244 }); 245 } 246 247 // âââ 3. Sort Dropdown âââââââââââââââââââââââââââââââââââââââââââ 248 function bindSortDropdown() { 249 if (!sortDropdown) return; 250 var sortBtn = sortDropdown.querySelector('.sort-btn'); 251 if (!sortBtn) return; 252 253 sortBtn.addEventListener('click', function(e) { 254 e.stopPropagation(); 255 sortDropdown.classList.toggle('open'); 256 if (artistDropdown) artistDropdown.classList.remove('open'); 257 }); 258 259 var options = sortDropdown.querySelectorAll('.sort-option'); 260 for (var i = 0; i < options.length; i++) { 261 options[i].addEventListener('click', function(e) { 262 var option = e.currentTarget; 263 var sort = option.getAttribute('data-sort'); 264 if (!sort) return; 265 266 activeSort = sort; 267 if (sortLabel) sortLabel.textContent = option.textContent; 268 269 var allOpts = sortDropdown.querySelectorAll('.sort-option'); 270 for (var j = 0; j < allOpts.length; j++) { 271 allOpts[j].classList.remove('active'); 272 } 273 option.classList.add('active'); 274 275 sortDropdown.classList.remove('open'); 276 applyFiltersAndSort(); 277 }); 278 } 279 } 280 281 // âââ 4. Artist Filter Dropdown ââââââââââââââââââââââââââââââââââ 282 function bindArtistDropdown() { 283 if (!artistDropdown) return; 284 var artistBtn = artistDropdown.querySelector('.artist-btn'); 285 if (!artistBtn) return; 286 287 artistBtn.addEventListener('click', function(e) { 288 e.stopPropagation(); 289 artistDropdown.classList.toggle('open'); 290 if (sortDropdown) sortDropdown.classList.remove('open'); 291 }); 292 293 var options = artistDropdown.querySelectorAll('.artist-option'); 294 for (var i = 0; i < options.length; i++) { 295 options[i].addEventListener('click', function(e) { 296 var option = e.currentTarget; 297 var artistVal = option.getAttribute('data-artist'); 298 299 activeArtistId = (artistVal && artistVal !== 'all') ? parseInt(artistVal, 10) : null; 300 301 // Update label 302 var label = document.getElementById('artist-filter-label'); 303 if (label) label.textContent = option.textContent; 304 305 var allOpts = artistDropdown.querySelectorAll('.artist-option'); 306 for (var j = 0; j < allOpts.length; j++) { 307 allOpts[j].classList.remove('active'); 308 } 309 option.classList.add('active'); 310 311 artistDropdown.classList.remove('open'); 312 applyFiltersAndSort(); 313 }); 314 } 315 } 316 317 // âââ Close dropdowns on outside click âââââââââââââââââââââââââââ 318 document.addEventListener('click', function() { 319 if (sortDropdown) sortDropdown.classList.remove('open'); 320 if (artistDropdown) artistDropdown.classList.remove('open'); 321 }); 322 323 // âââ 5. View Toggle âââââââââââââââââââââââââââââââââââââââââââââ 324 function bindViewToggle() { 325 var toggles = document.querySelectorAll('.view-toggle'); 326 for (var i = 0; i < toggles.length; i++) { 327 toggles[i].addEventListener('click', function(e) { 328 var btn = e.currentTarget; 329 var view = btn.getAttribute('data-view'); 330 331 var allToggles = document.querySelectorAll('.view-toggle'); 332 for (var j = 0; j < allToggles.length; j++) { 333 allToggles[j].classList.remove('active'); 334 } 335 btn.classList.add('active'); 336 337 if (grid) { 338 grid.classList.remove('large-view', 'list-view'); 339 if (view === 'large') { 340 grid.classList.add('large-view'); 341 } else if (view === 'list') { 342 grid.classList.add('list-view'); 343 } 344 } 345 }); 346 } 347 } 348 349 // âââ 6. Voting ââââââââââââââââââââââââââââââââââââââââââââââââââ 350 function castVote(artworkId) { 351 if (!window.ASH.votingEnabled) return; 352 if (window.ASH.votesRemaining <= 0) { 353 showToast('No votes remaining! Votes refresh every 12 hours.', 'error'); 354 return; 355 } 356 357 fetch('/api/shows/' + window.ASH.showId + '/vote', { 358 method: 'POST', 359 headers: { 'Content-Type': 'application/json' }, 360 body: JSON.stringify({ 361 artwork_id: artworkId, 362 _csrf_token: window.ASH.csrfToken 363 }) 364 }) 365 .then(function(r) { return r.json(); }) 366 .then(function(data) { 367 if (data.success) { 368 window.ASH.votesRemaining = data.votes_remaining; 369 updateVotesIndicator(); 370 371 var artwork = findArtwork(artworkId); 372 if (artwork) { 373 // Not storing the returned tally - ordering only refreshes on 374 // reload, which is fine and keeps counts off the client. 375 artwork.user_votes++; 376 artwork.voted_this_cycle = true; 377 } 378 379 updateArtworkVoteUI(artworkId); 380 showToast('Vote cast!', 'success'); 381 } else { 382 showToast(data.message || 'Could not vote.', 'error'); 383 } 384 }) 385 .catch(function() { 386 showToast('Network error. Please try again.', 'error'); 387 }); 388 } 389 390 // âââ 7. Favorites âââââââââââââââââââââââââââââââââââââââââââââââ 391 function toggleFavorite(artworkId) { 392 if (!window.ASH.isLoggedIn) { 393 showToast('Log in to save favorites.', 'error'); 394 return; 395 } 396 397 fetch('/api/shows/' + window.ASH.showId + '/favorite', { 398 method: 'POST', 399 headers: { 'Content-Type': 'application/json' }, 400 body: JSON.stringify({ 401 artwork_id: artworkId, 402 _csrf_token: window.ASH.csrfToken 403 }) 404 }) 405 .then(function(r) { return r.json(); }) 406 .then(function(data) { 407 var artwork = findArtwork(artworkId); 408 if (artwork) artwork.favorited = data.favorited; 409 updateArtworkFavoriteUI(artworkId); 410 }) 411 .catch(function() { 412 showToast('Network error. Please try again.', 'error'); 413 }); 414 } 415 416 // âââ 8. Quick View Modal ââââââââââââââââââââââââââââââââââââââââ 417 function bindQuickView() { 418 if (!quickviewModal) return; 419 420 // Close button 421 var closeBtn = quickviewModal.querySelector('.quickview-close'); 422 if (closeBtn) { 423 closeBtn.addEventListener('click', closeQuickView); 424 } 425 426 // Click on overlay background (not the inner modal) to close 427 quickviewModal.addEventListener('click', function(e) { 428 if (e.target === quickviewModal) closeQuickView(); 429 }); 430 431 // Vote button inside modal 432 var modalVoteBtn = quickviewModal.querySelector('.quickview-vote-btn'); 433 if (modalVoteBtn) { 434 modalVoteBtn.addEventListener('click', function() { 435 var artworkId = parseInt(quickviewModal.getAttribute('data-artwork-id'), 10); 436 if (artworkId) castVote(artworkId); 437 }); 438 } 439 440 // Favorite button inside modal 441 var modalFavBtn = quickviewModal.querySelector('.quickview-fav-btn'); 442 if (modalFavBtn) { 443 modalFavBtn.addEventListener('click', function() { 444 var artworkId = parseInt(quickviewModal.getAttribute('data-artwork-id'), 10); 445 if (artworkId) toggleFavorite(artworkId); 446 }); 447 } 448 449 // Add to Cart button inside modal - triggers the grid card's button 450 // which is handled by cart.js event delegation 451 var modalCartBtn = quickviewModal.querySelector('.quickview-cart-btn'); 452 if (modalCartBtn) { 453 modalCartBtn.addEventListener('click', function() { 454 var artworkId = parseInt(quickviewModal.getAttribute('data-artwork-id'), 10); 455 if (!artworkId || !grid) return; 456 var card = grid.querySelector('.artwork-card[data-artwork-id="' + artworkId + '"]'); 457 if (card) { 458 var cartBtn = card.querySelector('.add-to-cart-btn'); 459 if (cartBtn) cartBtn.click(); 460 } 461 }); 462 } 463 } 464 465 function openQuickView(artworkId) { 466 if (!quickviewModal) return; 467 468 var artwork = findArtwork(artworkId); 469 if (!artwork) return; 470 471 quickviewModal.setAttribute('data-artwork-id', artworkId); 472 473 // Image 474 var img = quickviewModal.querySelector('.quickview-img'); 475 if (img) { 476 img.src = artwork.primary_image_url; 477 img.alt = artwork.title; 478 } 479 480 // Title & artist 481 var titleEl = quickviewModal.querySelector('.quickview-title'); 482 if (titleEl) titleEl.textContent = artwork.title; 483 484 var artistEl = quickviewModal.querySelector('.quickview-artist'); 485 if (artistEl) artistEl.textContent = 'by ' + artwork.artist_name; 486 487 // Price 488 var priceEl = quickviewModal.querySelector('.quickview-price'); 489 if (priceEl) { 490 if (artwork.is_nfs) { 491 priceEl.textContent = 'Not For Sale'; 492 } else if (artwork.status === 'sold') { 493 priceEl.textContent = 'Sold'; 494 } else if (artwork.price !== null && artwork.price !== undefined) { 495 priceEl.textContent = '$' + formatPrice(artwork.price); 496 } else if (artwork.price_label) { 497 // Price on request, contact the gallery, open to offers. 498 priceEl.textContent = artwork.price_label; 499 } else { 500 priceEl.textContent = ''; 501 } 502 } 503 504 // No public vote tally - see the note in layouts/public.php. Only the 505 // viewer's own vote count is shown, which is an allowance not a tally. 506 507 // Stats - your votes 508 var myVotesStat = quickviewModal.querySelector('.quickview-my-votes-stat'); 509 var myVotesEl = quickviewModal.querySelector('.quickview-my-votes'); 510 if (myVotesStat && myVotesEl) { 511 if (artwork.user_votes > 0) { 512 myVotesEl.textContent = artwork.user_votes; 513 myVotesStat.style.display = ''; 514 } else { 515 myVotesStat.style.display = 'none'; 516 } 517 } 518 519 // Description 520 var descEl = quickviewModal.querySelector('.quickview-description'); 521 if (descEl) { 522 descEl.textContent = artwork.description || ''; 523 descEl.style.display = artwork.description ? '' : 'none'; 524 } 525 526 // Vote button state 527 var modalVoteBtn = quickviewModal.querySelector('.quickview-vote-btn'); 528 var modalVoteText = quickviewModal.querySelector('.quickview-vote-text'); 529 if (modalVoteBtn) { 530 if (window.ASH.votingEnabled) { 531 modalVoteBtn.style.display = ''; 532 if (artwork.voted_this_cycle) { 533 modalVoteBtn.classList.add('voted'); 534 if (modalVoteText) modalVoteText.textContent = 'Voted'; 535 } else { 536 modalVoteBtn.classList.remove('voted'); 537 if (modalVoteText) modalVoteText.textContent = 'Vote'; 538 } 539 } else { 540 modalVoteBtn.style.display = 'none'; 541 } 542 } 543 544 // Favorite button state 545 var modalFavBtn = quickviewModal.querySelector('.quickview-fav-btn'); 546 var modalFavText = quickviewModal.querySelector('.quickview-fav-text'); 547 var modalFavSvg = quickviewModal.querySelector('.quickview-fav-svg'); 548 if (modalFavBtn) { 549 if (artwork.favorited) { 550 modalFavBtn.classList.add('active'); 551 if (modalFavSvg) modalFavSvg.setAttribute('fill', 'currentColor'); 552 if (modalFavText) modalFavText.textContent = 'Favorited'; 553 } else { 554 modalFavBtn.classList.remove('active'); 555 if (modalFavSvg) modalFavSvg.setAttribute('fill', 'none'); 556 if (modalFavText) modalFavText.textContent = 'Favorite'; 557 } 558 } 559 560 // Add to Cart button - show only if purchasable 561 var modalCartBtn = quickviewModal.querySelector('.quickview-cart-btn'); 562 if (modalCartBtn) { 563 var canBuy = !artwork.is_nfs && artwork.status !== 'sold' && artwork.price !== null && artwork.price > 0; 564 modalCartBtn.style.display = canBuy ? '' : 'none'; 565 } 566 567 // Show modal and lock scroll 568 quickviewModal.classList.add('active'); 569 document.body.classList.add('modal-open'); 570 } 571 572 function closeQuickView() { 573 if (!quickviewModal) return; 574 quickviewModal.classList.remove('active'); 575 document.body.classList.remove('modal-open'); 576 } 577 578 // âââ 9. Surprise Me âââââââââââââââââââââââââââââââââââââââââââââ 579 function bindSurpriseMe() { 580 var btn = document.getElementById('surprise-btn'); 581 if (!btn) return; 582 583 btn.addEventListener('click', function() { 584 // Get visible cards (class-based - inline display is not used on cards) 585 var cards = grid.querySelectorAll('.artwork-card'); 586 var visible = []; 587 for (var i = 0; i < cards.length; i++) { 588 if (!cards[i].classList.contains('ash-hidden') && !cards[i].classList.contains('ash-paged-out')) { 589 visible.push(cards[i]); 590 } 591 } 592 if (visible.length === 0) { 593 showToast('No artworks to show!', 'error'); 594 return; 595 } 596 var randomCard = visible[Math.floor(Math.random() * visible.length)]; 597 var artworkId = parseInt(randomCard.getAttribute('data-artwork-id'), 10); 598 if (artworkId) openQuickView(artworkId); 599 }); 600 } 601 602 // âââ 10. User Menu Dropdown âââââââââââââââââââââââââââââââââââââ 603 function bindUserMenu() { 604 if (!userToggle || !userDropdown) return; 605 606 userToggle.addEventListener('click', function(e) { 607 e.stopPropagation(); 608 userDropdown.classList.toggle('active'); 609 }); 610 611 document.addEventListener('click', function() { 612 if (userDropdown) userDropdown.classList.remove('active'); 613 }); 614 } 615 616 // âââ 11. Toast Notifications ââââââââââââââââââââââââââââââââââââ 617 function showToast(message, type) { 618 if (!toast) return; 619 if (toastMessage) { 620 toastMessage.textContent = message; 621 } else { 622
622 toast.textContent = message; 623 } 624 toast.className = 'toast show ' + (type || ''); 625 clearTimeout(window._toastTimer); 626 window._toastTimer = setTimeout(function() { 627 toast.className = 'toast'; 628 }, 3000); 629 } 630 631 // âââ 12. Votes Indicator Update âââââââââââââââââââââââââââââââââ 632 function updateVotesIndicator() { 633 var el = document.getElementById('votes-count'); 634 if (el) el.textContent = window.ASH.votesRemaining; 635 636 var refreshEl = document.getElementById('votes-refresh'); 637 if (refreshEl) { 638 if (window.ASH.votesRemaining < window.ASH.votesMax && window.ASH.nextRefresh) { 639 refreshEl.style.display = ''; 640 } else { 641 refreshEl.style.display = 'none'; 642 } 643 } 644 } 645 646 // âââ 13. Grid Filtering, Sorting, and Reordering ââââââââââââââââ 647 function applyFiltersAndSort() { 648 var artworks = window.ASH.artworks; 649 if (!artworks || !grid) return; 650 651 // Build a lookup of artwork IDs that pass filters 652 var visibleIds = {}; 653 var visibleList = []; 654 655 for (var i = 0; i < artworks.length; i++) { 656 var a = artworks[i]; 657 if (passesFilter(a) && passesSearch(a) && passesArtist(a) && passesPanelFilters(a)) { 658 visibleIds[a.id] = true; 659 visibleList.push(a); 660 } 661 } 662 663 // Sort the visible list. While a search is active, relevance outranks 664 // the chosen sort (best title match first), with the sort as tiebreak. 665 var sorted = sortArtworks(visibleList); 666 if (searchQuery) { 667 var pos = {}; 668 for (var s = 0; s < sorted.length; s++) pos[sorted[s].id] = s; 669 sorted = sorted.slice().sort(function(a, b) { 670 var diff = searchScore(b) - searchScore(a); 671 return diff !== 0 ? diff : pos[a.id] - pos[b.id]; 672 }); 673 } 674 675 // Get all card elements 676 var cards = grid.querySelectorAll('.artwork-card'); 677 var cardMap = {}; 678 for (var j = 0; j < cards.length; j++) { 679 var cardId = parseInt(cards[j].getAttribute('data-artwork-id'), 10); 680 cardMap[cardId] = cards[j]; 681 } 682 683 // Hide non-visible cards, show visible ones. MUST be a class: the 684 // layout system styles cards with `display: ... !important`, which 685 // beats a non-important inline style - inline display:none silently 686 // never hid anything (the filter looked completely dead on screen). 687 for (var k = 0; k < cards.length; k++) { 688 var cId = parseInt(cards[k].getAttribute('data-artwork-id'), 10); 689 cards[k].classList.toggle('ash-hidden', !visibleIds[cId]); 690 cards[k].style.display = ''; 691 } 692 693 // Reorder visible cards in sorted order 694 for (var m = 0; m < sorted.length; m++) { 695 var card = cardMap[sorted[m].id]; 696 if (card) { 697 grid.appendChild(card); 698 } 699 } 700 701 // Masonry columns flow top-to-bottom, which scrambles a ranked order 702 // when read left-to-right. Flag ordered mode (explicit sort or active 703 // search) so the CSS switches the grid to strict row flow. 704 var page = document.querySelector('.show-page'); 705 if (page) { 706 page.dataset.ordered = (activeSort !== 'featured' || searchQuery) ? '1' : '0'; 707 } 708 709 // Update results count 710 if (resultsCount) { 711 resultsCount.textContent = sorted.length; 712 } 713 714 // Apply layout-specific paging (paginated / infinite scroll) 715 currentPage = 1; 716 applyLayoutPaging(); 717 } 718 719 function passesFilter(artwork) { 720 switch (activeFilter) { 721 case 'available': 722 return !artwork.is_nfs && artwork.status !== 'sold'; 723 case 'top_voted': 724 return true; 725 case 'sold': 726 return artwork.status === 'sold'; 727 case 'all': 728 default: 729 return true; 730 } 731 } 732 733 function passesSearch(artwork) { 734 if (!searchQuery) return true; 735 // Every word of the query must appear somewhere in title or artist, 736 // in any order ("7 title" still finds "Demo Title 7"). 737 var haystack = ((artwork.title || '') + ' ' + (artwork.artist_name || '')).toLowerCase(); 738 var tokens = searchQuery.split(/\s+/); 739 for (var i = 0; i < tokens.length; i++) { 740 if (tokens[i] && haystack.indexOf(tokens[i]) === -1) return false;
741 } 742 return true; 743 } 744 745 // Relevance for active searches: exact title, then title starts-with, 746 // then earliest in-title match, then artist matches. 747 function searchScore(artwork) { 748 var q = searchQuery; 749 var title = (artwork.title || '').toLowerCase(); 750 var artist = (artwork.artist_name || '').toLowerCase(); 751 if (title === q) return 100; 752 if (title.indexOf(q) === 0) return 90; 753 var idx = title.indexOf(q); 754 if (idx > 0) return Math.max(55, 75 - idx); 755 if (artist.indexOf(q) === 0) return 45; 756 if (artist.indexOf(q) !== -1) return 35; 757 return 10; // token-matched across title+artist but not as a phrase 758 } 759 760 function passesArtist(artwork) { 761 if (!activeArtistId) return true; 762 return artwork.artist_id === activeArtistId; 763 } 764 765 // Filters panel: price cap, type, size. Unpriced/NFS pieces are never 766 // hidden by the price cap - it's a budget filter, not an availability one. 767 function passesPanelFilters(artwork) { 768 if (activePriceMax !== null && !artwork.is_nfs 769 && artwork.price !== null && artwork.price !== undefined 770 && artwork.price > activePriceMax) { 771 return false; 772 } 773 if (activeType && artwork.type !== activeType) return false; 774 if (activeSize && artwork.size !== activeSize) return false; 775 return true; 776 } 777 778 // âââ Filters Panel (price / type / size) ââââââââââââââââââââââââ 779 // The controls live either inside a top-bar popover (#ashf-dropdown) or 780 // directly in the right rail / mobile sheet - bind whatever exists. 781 function bindFiltersPanel() { 782 var dropdown = document.getElementById('ashf-dropdown'); 783 // The facet controls are rendered TWICE (top-bar popover and the right 784 // rail / mobile sheet) with the same ids, so getElementById only ever 785 // wired the first copy: the rail's Size and Type selects did nothing 786 // (Jennifer, report 88). Bind every copy and keep them in step. 787 var all = function (id) { return Array.prototype.slice.call(document.querySelectorAll('[id="' + id + '"]')); }; 788 var prices = all('ashf-price'), priceDisplays = all('ashf-price-display'); 789 var typeSels = all('ashf-type'), sizeSels = all('ashf-size'); 790 var price = prices[0] || null; 791 var priceDisplay = priceDisplays[0] || null; 792 var typeSel = typeSels[0] || null; 793 var sizeSel = sizeSels[0] || null; 794 var syncAll = function (els, value) { els.forEach(function (el) { el.value = value; }); }; 795 var showPrice = function (v) { priceDisplays.forEach(function (d) { d.textContent = '$' + parseInt(v, 10).toLocaleString(); }); }; 796 // Both the sidebar panel and the top bar render a Reset button, so bind 797 // every one of them - getElementById only ever found the first. 798 var resets = document.querySelectorAll('.ashf-reset'); 799 800 if (dropdown) { 801 var btn = document.getElementById('ashf-btn'); 802 var menu = document.getElementById('ashf-menu'); 803 btn.addEventListener('click', function(e) { 804 e.stopPropagation(); 805 dropdown.classList.toggle('open'); 806 if (sortDropdown) sortDropdown.classList.remove('open'); 807 if (artistDropdown) artistDropdown.classList.remove('open'); 808 }); 809 // Interacting with the controls must not close the panel. 810 menu.addEventListener('click', function(e) { e.stopPropagation(); }); 811 document.addEventListener('click', function() { dropdown.classList.remove('open'); }); 812 } 813 814 function updateDot() { 815 var maxAttr = price ? parseInt(price.getAttribute('max'), 10) : null; 816 var priceActive = activePriceMax !== null && maxAttr !== null && activePriceMax < maxAttr; 817 var anyActive = !!(priceActive || activeType || activeSize || activeFilter !== 'all'); 818 ['ashf-dot', 'rail-mini-dot'].forEach(function(id) { 819 var d = document.getElementById(id); 820 if (d) d.hidden = !anyActive; 821 }); 822 } 823 // The availability tabs also count as "filters active" - refresh the 824 // dot whenever they change. 825 document.querySelectorAll('.filter-tab').forEach(function(t) { 826 t.addEventListener('click', function() { setTimeout(updateDot, 0); }); 827 }); 828
829 prices.forEach(function (pr) { 830 pr.addEventListener('input', function() { 831 var v = parseInt(pr.value, 10); 832 syncAll(prices, pr.value); 833 showPrice(v); 834 clearTimeout(priceTimer); 835 priceTimer = setTimeout(function() { 836 activePriceMax = v >= parseInt(pr.getAttribute('max'), 10) ? null : v; 837 updateDot(); 838 applyFiltersAndSort(); 839 }, 150); 840 }); 841 }); 842 typeSels.forEach(function (sel) { 843 sel.addEventListener('change', function() { 844 activeType = sel.value; 845 syncAll(typeSels, sel.value); 846 updateDot(); 847 applyFiltersAndSort(); 848 }); 849 }); 850 sizeSels.forEach(function (sel) { 851 sel.addEventListener('change', function() { 852 activeSize = sel.value; 853 syncAll(sizeSels, sel.value); 854 updateDot(); 855 applyFiltersAndSort(); 856 }); 857 }); 858 resets.forEach(function (reset) { 859 reset.addEventListener('click', function() { 860 activePriceMax = null; 861 activeType = ''; 862 activeSize = ''; 863 if (price) { 864 syncAll(prices, price.getAttribute('max')); 865 showPrice(price.getAttribute('max')); 866 } 867 syncAll(typeSels, ''); 868 syncAll(sizeSels, ''); 869 // Availability lives in the filters group now - reset it too. 870 var allTab = document.querySelector('.filter-tab[data-filter="all"]'); 871 if (allTab && activeFilter !== 'all') allTab.click(); 872 updateDot(); 873 applyFiltersAndSort(); 874 }); 875 }); 876 } 877 878 // âââ Refresh Art (Artburst parity: reshuffle, newest pieces first) ââ 879 function bindRefreshArt() { 880 var btn = document.getElementById('refresh-art-btn'); 881 if (!btn) return; 882 btn.addEventListener('click', function() { 883 var icon = document.getElementById('refresh-art-icon'); 884 if (icon) { 885 icon.classList.remove('spinning'); 886 void icon.offsetWidth; // restart the animation 887 icon.classList.add('spinning'); 888 } 889 // Snap the sort back to the default shuffle so the label stays 890 // honest ("Featured" is gone from the menu; shuffle is the default). 891 activeSort = 'featured'; 892 if (sortLabel) sortLabel.textContent = 'Shuffled'; 893 if (sortDropdown) { 894 var opts = sortDropdown.querySelectorAll('.sort-option'); 895 for (var i = 0; i < opts.length; i++) { 896 opts[i].classList.toggle('active', opts[i].getAttribute('data-sort') === 'featured'); 897 } 898 } 899 refreshShufflePending = true; 900 applyFiltersAndSort(); 901 }); 902 } 903 904 function sortArtworks(list) { 905 var arr = list.slice(); 906 907 switch (activeSort) { 908 case 'featured': 909 return sortFeatured(arr); 910 911 case 'most_voted': 912 // vote_rank is an opaque position (1 = most voted), not a tally. 913 arr.sort(function(a, b) { return a.vote_rank - b.vote_rank; }); 914 return arr; 915 916 case 'price_high': 917 arr.sort(function(a, b) { 918 var aPrice = (a.is_nfs || a.price === null || a.price === undefined) ? -1 : a.price; 919 var bPrice = (b.is_nfs || b.price === null || b.price === undefined) ? -1 : b.price; 920 if (aPrice === -1 && bPrice === -1) return 0; 921 if (aPrice === -1) return 1; 922 if (bPrice === -1) return -1; 923 return bPrice - aPrice; 924 }); 925 return arr; 926 927 case 'price_low': 928 arr.sort(function(a, b) { 929 var aPrice = (a.is_nfs || a.price === null || a.price === undefined) ? Infinity : a.price; 930 var bPrice = (b.is_nfs || b.price === null || b.price === undefined) ? Infinity : b.price; 931 if (aPrice === Infinity && bPrice === Infinity) return 0; 932 if (aPrice === Infinity) return 1; 933 if (bPrice === Infinity) return -1; 934 return aPrice - bPrice; 935 }); 936 return arr; 937 938 case 'newest': 939 var indexMap = {}; 940 for (var i = 0; i < window.ASH.artworks.length; i++) { 941 indexMap[window.ASH.artworks[i].id] = i; 942 } 943 arr.sort(function(a, b) { 944 return (indexMap[a.id] || 0) - (indexMap[b.id] || 0); 945 }); 946 return arr; 947 948 case 'artist_az': 949 arr.sort(function(a, b) { 950 var nameA = (a.artist_name || '').toLowerCase(); 951 var nameB = (b.artist_name || '').toLowerCase(); 952 if (nameA < nameB) return -1; 953 if (nameA > nameB) return 1; 954 return 0; 955 }); 956 return arr; 957 958 default: 959 return arr; 960 } 961 } 962 963 function sortFeatured(arr) { 964 // Refresh Art: full fresh shuffle with the newest pieces boosted to 965 // the front (the plugin's novelty rule), ignoring vote pinning. 966 if (refreshShufflePending) { 967 refreshShufflePending = false;
968 return freshShuffle(arr); 969 } 970 971 var byvotes = arr.slice().sort(function(a, b) { return a.vote_rank - b.vote_rank; }); 972 var top = byvotes.slice(0, 3); 973 var rest = byvotes.slice(3); 974 975 // Shuffle the rest (Fisher-Yates) 976 for (var i = rest.length - 1; i > 0; i--) { 977 var j = Math.floor(Math.random() * (i + 1)); 978 var temp = rest[i]; 979 rest[i] = rest[j]; 980 rest[j] = temp; 981 } 982 983 return top.concat(rest); 984 } 985 986 function freshShuffle(arr) { 987 var shuffled = arr.slice(); 988 for (var i = shuffled.length - 1; i > 0; i--) { 989 var j = Math.floor(Math.random() * (i + 1)); 990 var t = shuffled[i]; 991 shuffled[i] = shuffled[j]; 992 shuffled[j] = t; 993 } 994 if (shuffled.length <= 4) return shuffled; 995 996 // Pull the three newest pieces to the front, shuffled among themselves. 997 var newestIds = arr.slice() 998 .sort(function(a, b) { return (b.created || 0) - (a.created || 0); }) 999 .slice(0, 3) 1000 .map(function(a) { return a.id; }); 1001 var fresh = []; 1002 var others = []; 1003 for (var k = 0; k < shuffled.length; k++) { 1004 (newestIds.indexOf(shuffled[k].id) !== -1 ? fresh : others).push(shuffled[k]); 1005 } 1006 return fresh.concat(others); 1007 } 1008 1009 // âââ Grid Event Delegation ââââââââââââââââââââââââââââââââââââââ 1010 function bindGridDelegation() { 1011 if (!grid) return; 1012 1013 grid.addEventListener('click', function(e) { 1014 var target = e.target; 1015 1016 // Vote button (grid: .vote-btn, list: .vote-action) 1017 var voteBtn = target.closest('.vote-btn') || target.closest('.vote-action'); 1018 if (voteBtn) { 1019 e.preventDefault(); 1020 var artworkId = parseInt(voteBtn.getAttribute('data-artwork-id'), 10); 1021 if (artworkId) castVote(artworkId); 1022 return; 1023 } 1024 1025 // Favorite button (grid: .favorite-btn, list: .favorite-action) 1026 var favBtn = target.closest('.favorite-btn') || target.closest('.favorite-action'); 1027 if (favBtn) { 1028 e.preventDefault(); 1029 var favArtworkId = parseInt(favBtn.getAttribute('data-artwork-id'), 10); 1030 if (favArtworkId) toggleFavorite(favArtworkId); 1031 return; 1032 } 1033 1034 // Quick view button (grid: .quickview-btn, list: .preview-action) 1035 var qvBtn = target.closest('.quickview-btn') || target.closest('.preview-action'); 1036 if (qvBtn) { 1037 e.preventDefault(); 1038 var qvArtworkId = parseInt(qvBtn.getAttribute('data-artwork-id'), 10); 1039 if (qvArtworkId) openQuickView(qvArtworkId); 1040 return; 1041 } 1042 }); 1043 } 1044 1045 // âââ Keyboard Bindings ââââââââââââââââââââââââââââââââââââââââââ 1046 function bindKeyboard() { 1047 document.addEventListener('keydown', function(e) { 1048 if (e.key === 'Escape' || e.keyCode === 27) { 1049 closeQuickView(); 1050 } 1051 }); 1052 } 1053 1054 // âââ UI Update Helpers ââââââââââââââââââââââââââââââââââââââââââ 1055 function updateArtworkVoteUI(artworkId) { 1056 var artwork = findArtwork(artworkId); 1057 if (!artwork) return; 1058 1059 // Find the card in the grid 1060 var card = grid ? grid.querySelector('.artwork-card[data-artwork-id="' + artworkId + '"]') : null; 1061 if (card) { 1062 // Deliberately no vote tally written to the card - counts stay private 1063 // to owners/admins on /voting. 1064 1065 // Update vote buttons state (grid .vote-btn and list .vote-action) 1066 var voteBtns = card.querySelectorAll('.vote-btn, .vote-action'); 1067 for (var i = 0; i < voteBtns.length; i++) { 1068 if (artwork.voted_this_cycle) { 1069 voteBtns[i].classList.add('voted'); 1070 var span = voteBtns[i].querySelector('span'); 1071 if (span) span.textContent = 'Voted'; 1072 } else { 1073 voteBtns[i].classList.remove('voted'); 1074 var spanR = voteBtns[i].querySelector('span'); 1075 if (spanR) spanR.textContent = 'Vote'; 1076 } 1077 } 1078 1079 // Update my-votes badge 1080 var myVotesBadge = card.querySelector('.my-votes-badge'); 1081 if (artwork.user_votes > 0) { 1082 if (!myVotesBadge) { 1083 // Create badge if it doesn't exist 1084 var wrapper = card.querySelector('.artwork-image-wrapper'); 1085 if (wrapper) { 1086 myVotesBadge = document.createElement('span'); 1087 myVotesBadge.className = 'my-votes-badge'; 1088 wrapper.appendChild(myVotesBadge); 1089 } 1090 } 1091 if (myVotesBadge) myVotesBadge.textContent = 'You: x' + artwork.user_votes; 1092 } 1093 } 1094 1095 // Update quickview modal if showing this artwork 1096 if (quickviewModal && quickviewModal.getAttribute('data-artwork-id') === String(artworkId)) { 1097 // No public tally in the modal either - only the viewer's own votes. 1098 var modalMyVotesStat = quickviewModal.querySelector('.quickview-my-votes-stat'); 1099 var modalMyVotes = quickviewModal.querySelector('.quickview-my-votes'); 1100 if (modalMyVotesStat && modalMyVotes) { 1101 if (artwork.user_votes > 0) { 1102 modalMyVotes.textContent = artwork.user_votes; 1103 modalMyVotesStat.style.display = ''; 1104 } 1105 } 1106 1107 var modalVoteBtn = quickviewModal.querySelector('.quickview-vote-btn'); 1108 var modalVoteText = quickviewModal.querySelector('.quickview-vote-text'); 1109 if (modalVoteBtn) { 1110 if (artwork.voted_this_cycle) { 1111 modalVoteBtn.classList.add('voted'); 1112 if (modalVoteText) modalVoteText.textContent = 'Voted'; 1113 } else { 1114 modalVoteBtn.classList.remove('voted'); 1115 if (modalVoteText) modalVoteText.textContent = 'Vote'; 1116 } 1117 } 1118 } 1119 } 1120 1121 function updateArtworkFavoriteUI(artworkId) { 1122 var artwork = findArtwork(artworkId); 1123 if (!artwork) return; 1124 1125 // Find the card in the grid 1126 var card = grid ? grid.querySelector('.artwork-card[data-artwork-id="' + artworkId + '"]') : null; 1127 if (card) { 1128 // Update grid overlay favorite button 1129 var favBtns = card.querySelectorAll('.favorite-btn, .favorite-action'); 1130 for (var i = 0; i < favBtns.length; i++) { 1131 var svg = favBtns[i].querySelector('svg'); 1132 if (artwork.favorited) { 1133 favBtns[i].classList.add('active'); 1134 if (svg) svg.setAttribute('fill', 'currentColor'); 1135 } else { 1136 favBtns[i].classList.remove('active'); 1137 if (svg) svg.setAttribute('fill', 'none'); 1138 } 1139 } 1140 } 1141 1142 // Update quickview modal if showing this artwork 1143 if (quickviewModal && quickviewModal.getAttribute('data-artwork-id') === String(artworkId)) { 1144 var modalFavBtn = quickviewModal.querySelector('.quickview-fav-btn'); 1145 var modalFavText = quickviewModal.querySelector('.quickview-fav-text'); 1146 var modalFavSvg = quickviewModal.querySelector('.quickview-fav-svg'); 1147 if (modalFavBtn) { 1148 if (artwork.favorited) { 1149 modalFavBtn.classList.add('active'); 1150 if (modalFavSvg) modalFavSvg.setAttribute('fill', 'currentColor'); 1151 if (modalFavText) modalFavText.textContent = 'Favorited'; 1152 } else { 1153 modalFavBtn.classList.remove('active'); 1154 if (modalFavSvg) modalFavSvg.setAttribute('fill', 'none'); 1155 if (modalFavText) modalFavText.textContent = 'Favorite'; 1156 } 1157 } 1158 } 1159 } 1160 1161 // âââ Utility ââââââââââââââââââââââââââââââââââââââââââââââââââââ 1162 function findArtwork(artworkId) { 1163 if (!window.ASH || !window.ASH.artworks) return null; 1164 return window.ASH.artworks.find(function(a) { return a.id === artworkId; }) || null; 1165 } 1166 1167 function formatPrice(price) { 1168 var num = parseFloat(price); 1169 if (isNaN(num)) return '0.00'; 1170 return num.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ','); 1171 } 1172 1173 // âââ Polyfill: Element.closest ââââââââââââââââââââââââââââââââââ 1174 if (!Element.prototype.closest) { 1175 Element.prototype.closest = function(selector) { 1176 var el = this; 1177 while (el) { 1178 if (el.matches(selector)) return el; 1179 el = el.parentElement; 1180 } 1181 return null; 1182 }; 1183 } 1184 1185 if (!Element.prototype.matches) { 1186 Element.prototype.matches = 1187 Element.prototype.msMatchesSelector || 1188 Element.prototype.webkitMatchesSelector; 1189 } 1190 1191 // âââ Email Signup Form ââââââââââââââââââââââââââââââââââââââââââ 1192 function bindEmailSignup() { 1193 var signupForm = document.getElementById('email-signup-form'); 1194 if (!signupForm) return; 1195 1196 signupForm.addEventListener('submit', function(e) { 1197 e.preventDefault(); 1198 var email = document.getElementById('signup-email').value; 1199 var name = document.getElementById('signup-name').value; 1200 var msgEl = document.getElementById('signup-message'); 1201 var showId = window.ASH && window.ASH.showId; 1202 1203 if (!showId) return; 1204 1205 fetch('/api/shows/' + showId + '/email-signup', { 1206 method: 'POST', 1207 headers: { 'Content-Type': 'application/json' }, 1208 body: JSON.stringify({ email: email, name: name }) 1209 }) 1210 .then(function(r) { return r.json(); }) 1211 .then(function(data) { 1212 msgEl.style.display = 'block'; 1213 if (data.success) { 1214 msgEl.style.color = '#10b981'; 1215 msgEl.textContent = data.message || 'Thanks for signing up!'; 1216 signupForm.reset(); 1217 } else { 1218 msgEl.style.color = '#ff6464'; 1219 msgEl.textContent = data.error || 'Something went wrong.'; 1220 } 1221 }) 1222 .catch(function() { 1223 msgEl.style.display = 'block'; 1224 msgEl.style.color = '#ff6464'; 1225 msgEl.textContent = 'Something went wrong.'; 1226 }); 1227 }); 1228 } 1229 1230 // âââ Boot âââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1231 if (document.readyState === 'loading') { 1232 document.addEventListener('DOMContentLoaded', function() { init(); bindEmailSignup(); }); 1233 } else { 1234 init(); 1235 bindEmailSignup(); 1236 } 1237 1238})();
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.