Files
pokemon/src/components/CardGrid.astro

400 lines
15 KiB
Plaintext
Raw Normal View History

2026-02-17 13:27:48 -05:00
---
import BackToTop from "./BackToTop.astro"
2026-04-05 10:17:43 -04:00
import LeftSidebarDesktop from "./LeftSidebarDesktop.astro"
2026-02-17 13:27:48 -05:00
---
<div class="row mb-4">
<div class="col-md-2">
<div class="h5 d-none">Inventory management placeholder</div>
<div class="offcanvas offcanvas-start" data-bs-backdrop="static" tabindex="-1" id="filterBar" aria-labelledby="filterBarLabel">
<div class="offcanvas-header">
<h5 class="offcanvas-title" id="filterBarLabel">Filter by:</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
</div>
<div class="offcanvas-body px-3 pt-0">
2026-02-26 15:51:00 -05:00
<div id="facetContainer"></div>
2026-02-17 13:07:29 -05:00
</div>
</div>
2026-04-05 10:17:43 -04:00
<LeftSidebarDesktop />
2026-02-26 18:38:07 -05:00
</div>
2026-03-05 22:59:16 -05:00
<div class="col-sm-12 col-md-10 mt-0">
<div class="d-flex flex-column gap-1 flex-md-row align-items-center mb-3 w-100 justify-content-start">
<div id="sortBy"></div>
<div id="totalResults"></div>
<div id="activeFilters"></div>
</div>
<div id="cardGrid" aria-live="polite" class="row g-xxl-3 g-2 row-cols-2 row-cols-md-3 row-cols-xl-4 row-cols-xxxl-5"></div>
<div id="notfound" aria-live="polite"></div>
</div>
</div>
<div class="modal card-modal" id="cardModal" tabindex="-1" aria-labelledby="cardModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-fullscreen-md-down modal-xl">
<div class="modal-content p-2">Loading...</div>
</div>
</div>
<button id="modalPrevBtn" class="modal-nav-btn modal-nav-prev d-none" aria-label="Previous card">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z"/>
</svg>
</button>
<button id="modalNextBtn" class="modal-nav-btn modal-nav-next d-none" aria-label="Next card">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z"/>
</svg>
</button>
<BackToTop />
<!-- <script src="src/assets/js/holofoil-init.js" is:inline></script> -->
<script is:inline>
(function () {
// ── Sort dropdown ─────────────────────────────────────────────────────────
document.addEventListener('click', (e) => {
const sortBy = document.getElementById('sortBy');
const btn = e.target.closest('#sortBy [data-bs-toggle="dropdown"]');
if (btn) {
e.preventDefault();
e.stopPropagation();
const menu = btn.nextElementSibling;
menu.classList.toggle('show');
btn.setAttribute('aria-expanded', menu.classList.contains('show'));
return;
}
const opt = e.target.closest('#sortBy .sort-option');
if (opt) {
e.preventDefault();
const menu = opt.closest('.dropdown-menu');
const btn2 = menu?.previousElementSibling;
menu?.classList.remove('show');
if (btn2) btn2.setAttribute('aria-expanded', 'false');
const sortInput = document.getElementById('sortInput');
if (sortInput) sortInput.value = opt.dataset.sort;
document.getElementById('sortLabel').textContent = opt.dataset.label;
document.querySelectorAll('.sort-option').forEach(o => o.classList.remove('active'));
opt.classList.add('active');
const start = document.getElementById('start');
if (start) start.value = '0';
document.getElementById('searchform').dispatchEvent(
new Event('submit', { bubbles: true, cancelable: true })
);
return;
}
const menu = document.querySelector('#sortBy .dropdown-menu.show');
if (menu) {
menu.classList.remove('show');
const btn3 = menu.previousElementSibling;
if (btn3) btn3.setAttribute('aria-expanded', 'false');
}
});
// ── Language toggle ───────────────────────────────────────────────────────
document.addEventListener('click', (e) => {
const btn = e.target.closest('.language-btn');
if (!btn) return;
e.preventDefault();
const input = document.getElementById('languageInput');
if (input) input.value = btn.dataset.lang;
const start = document.getElementById('start');
if (start) start.value = '0';
document.getElementById('searchform').dispatchEvent(
new Event('submit', { bubbles: true, cancelable: true })
);
});
// ── Global helpers ────────────────────────────────────────────────────────
window.copyImage = async function(img) {
try {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
// Load with crossOrigin so toBlob() stays untainted
await new Promise((resolve) => {
const clean = new Image();
clean.crossOrigin = 'anonymous';
clean.onload = () => { ctx.drawImage(clean, 0, 0); resolve(); };
clean.onerror = () => { ctx.drawImage(img, 0, 0); resolve(); };
clean.src = img.src;
});
if (navigator.clipboard && navigator.clipboard.write) {
const blob = await new Promise((resolve, reject) => {
canvas.toBlob(b => b ? resolve(b) : reject(new Error('toBlob failed')), 'image/png');
});
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
showCopyToast('📋 Image copied!', '#198754');
} else {
const url = img.src;
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(url);
showCopyToast('📋 Image URL copied!', '#198754');
} else {
const input = document.createElement('input');
input.value = url;
document.body.appendChild(input);
input.select();
document.execCommand('copy');
document.body.removeChild(input);
showCopyToast('📋 Image URL copied!', '#198754');
}
}
} catch (err) {
console.error('Failed:', err);
showCopyToast('❌ Copy failed', '#dc3545');
}
};
function showCopyToast(message, color) {
const toast = document.createElement('div');
toast.textContent = message;
toast.style.cssText = `
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
background: ${color}; color: white; padding: 10px 20px;
border-radius: 8px; font-size: 14px; z-index: 9999;
opacity: 0; transition: opacity 0.2s ease;
pointer-events: none;
`;
document.body.appendChild(toast);
requestAnimationFrame(() => toast.style.opacity = '1');
setTimeout(() => {
toast.style.opacity = '0';
toast.addEventListener('transitionend', () => toast.remove());
}, 2000);
}
// ── State ─────────────────────────────────────────────────────────────────
const cardIndex = [];
let currentCardId = null;
let isNavigating = false;
// ── Register cards as HTMX loads them ────────────────────────────────────
const cardGrid = document.getElementById('cardGrid');
const gridObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType !== 1) continue;
const triggers = node.querySelectorAll
? node.querySelectorAll('[data-card-id]')
: [];
for (const el of triggers) {
const id = Number(el.getAttribute('data-card-id'));
if (id && !cardIndex.includes(id)) cardIndex.push(id);
}
if (node.dataset?.cardId) {
const id = Number(node.dataset.cardId);
if (id && !cardIndex.includes(id)) cardIndex.push(id);
}
}
}
});
gridObserver.observe(cardGrid, { childList: true, subtree: true });
// ── Navigation helpers ────────────────────────────────────────────────────
function getAdjacentIds() {
const idx = cardIndex.indexOf(currentCardId);
return {
prev: idx > 0 ? cardIndex[idx - 1] : null,
next: idx < cardIndex.length - 1 ? cardIndex[idx + 1] : null,
idx,
total: cardIndex.length,
};
}
function updateNavButtons(modal) {
const prevBtn = document.getElementById('modalPrevBtn');
const nextBtn = document.getElementById('modalNextBtn');
if (!modal || !modal.classList.contains('show')) {
prevBtn.classList.add('d-none');
nextBtn.classList.add('d-none');
return;
}
const { prev, next } = getAdjacentIds();
prevBtn.classList.toggle('d-none', prev === null);
nextBtn.classList.toggle('d-none', next === null);
}
function tryTriggerSentinel() {
const sentinel = cardGrid.querySelector('[hx-trigger="revealed"]');
if (!sentinel) return;
if (typeof htmx !== 'undefined') {
htmx.trigger(sentinel, 'revealed');
} else {
sentinel.scrollIntoView({ behavior: 'instant', block: 'end' });
}
}
function initChartAfterSwap(modal) {
const canvas = modal.querySelector('#priceHistoryChart');
if (!canvas) return;
requestAnimationFrame(() => {
modal.dispatchEvent(new CustomEvent('card-modal:swapped', { bubbles: false }));
});
}
async function loadCard(cardId, direction = null) {
if (!cardId || isNavigating) return;
isNavigating = true;
currentCardId = cardId;
const modal = document.getElementById('cardModal');
const url = `/partials/card-modal?cardId=${cardId}`;
const { idx, total } = getAdjacentIds();
if (idx >= total - 3) tryTriggerSentinel();
const doSwap = async () => {
const response = await fetch(url);
const html = await response.text();
if (modal._reconnectChartObserver) modal._reconnectChartObserver();
modal.innerHTML = html;
if (typeof htmx !== 'undefined') htmx.process(modal);
updateNavButtons(modal);
initChartAfterSwap(modal);
};
if (document.startViewTransition && direction) {
modal.dataset.navDirection = direction;
await document.startViewTransition(doSwap).finished;
delete modal.dataset.navDirection;
} else {
await doSwap();
}
isNavigating = false;
const { idx: newIdx, total: newTotal } = getAdjacentIds();
if (newIdx >= newTotal - 3) tryTriggerSentinel();
}
function navigatePrev() {
const { prev } = getAdjacentIds();
if (prev) loadCard(prev, 'prev');
}
function navigateNext() {
const { next } = getAdjacentIds();
if (next) loadCard(next, 'next');
}
document.getElementById('modalPrevBtn').addEventListener('click', navigatePrev);
document.getElementById('modalNextBtn').addEventListener('click', navigateNext);
document.addEventListener('keydown', (e) => {
const modal = document.getElementById('cardModal');
if (!modal.classList.contains('show')) return;
if (e.key === 'ArrowLeft') { e.preventDefault(); navigatePrev(); }
if (e.key === 'ArrowRight') { e.preventDefault(); navigateNext(); }
});
let touchStartX = 0;
let touchStartY = 0;
const SWIPE_THRESHOLD = 50;
document.getElementById('cardModal').addEventListener('touchstart', (e) => {
touchStartX = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
}, { passive: true });
document.getElementById('cardModal').addEventListener('touchend', (e) => {
const dx = e.changedTouches[0].clientX - touchStartX;
const dy = e.changedTouches[0].clientY - touchStartY;
if (Math.abs(dx) < SWIPE_THRESHOLD || Math.abs(dy) > Math.abs(dx)) return;
if (dx < 0) navigateNext();
else navigatePrev();
}, { passive: true });
document.body.addEventListener('htmx:beforeRequest', async (e) => {
if (e.detail.elt.getAttribute('hx-target') !== '#cardModal') return;
const cardEl = e.detail.elt.closest('[data-card-id]');
if (cardEl) currentCardId = Number(cardEl.getAttribute('data-card-id'));
if (!document.startViewTransition) return;
e.preventDefault();
const url = e.detail.requestConfig.path;
const target = document.getElementById('cardModal');
const sourceImg = cardEl?.querySelector('img');
const response = await fetch(url, { headers: { 'HX-Request': 'true' } });
if (!response.ok) throw new Error(`Failed to load modal: ${response.status}`);
const html = await response.text();
const transitionName = `card-hero-${currentCardId}`;
try {
if (sourceImg) {
sourceImg.style.viewTransitionName = transitionName;
sourceImg.style.opacity = '0';
}
const transition = document.startViewTransition(async () => {
if (sourceImg) sourceImg.style.viewTransitionName = '';
if (target._reconnectChartObserver) target._reconnectChartObserver();
target.innerHTML = html;
if (typeof htmx !== 'undefined') htmx.process(target);
const destImg = target.querySelector('img.card-image');
if (destImg) {
destImg.style.viewTransitionName = transitionName;
if (!destImg.complete) {
await new Promise(resolve => {
destImg.addEventListener('load', resolve, { once: true });
destImg.addEventListener('error', resolve, { once: true });
});
}
}
});
await transition.finished;
updateNavButtons(target);
initChartAfterSwap(target);
} catch (err) {
console.error('[card-modal] transition failed:', err);
e.detail.elt.dispatchEvent(new MouseEvent('click', { bubbles: true }));
} finally {
if (sourceImg) {
sourceImg.style.viewTransitionName = '';
sourceImg.style.opacity = '';
}
const destImg = target.querySelector('img.card-image');
if (destImg) destImg.style.viewTransitionName = '';
}
});
const cardModal = document.getElementById('cardModal');
cardModal.addEventListener('shown.bs.modal', () => {
updateNavButtons(cardModal);
initChartAfterSwap(cardModal);
});
cardModal.addEventListener('hidden.bs.modal', () => {
currentCardId = null;
updateNavButtons(null);
});
2026-04-05 10:17:43 -04:00
// ── AdSense re-init on infinite scroll ───────────────────────────────────
document.addEventListener('htmx:afterSwap', () => {
(window.adsbygoogle = window.adsbygoogle || []).push({});
});
})();
</script>