PageSourceSearch

https://shows.artburststudios.com/assets/js/show.js?v=1791404372

js artburststudios.com collected 2026-10-10 01:35:35 UTC 53,163 bytes, 1,238 lines download raw bytes

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' : '') + '>&laquo;</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' : '') + '>&raquo;</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.