Files
pokemon/src/components/CardGrid.astro

333 lines
14 KiB
Plaintext
Raw Normal View History

2026-02-17 13:27:48 -05:00
---
import BackToTop from "./BackToTop.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-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-row align-items-center mb-2">
<div id="sortBy" class="mb-2 d-flex align-items-center justify-content-start small d-none">
<button class="btn btn-sm btn-dark dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Sort by</button>
<ul class="dropdown-menu dropdown-menu-dark">
<li><a class="dropdown-item" href="#">Price: High to Low</a></li>
<li><a class="dropdown-item" href="#">Price: Low to High</a></li>
<li><a class="dropdown-item" href="#">Set: Newest to Oldest</a></li>
<li><a class="dropdown-item" href="#">Set: Oldest to Newest</a></li>
<li><a class="dropdown-item" href="#">Card Number: Ascending</a></li>
<li><a class="dropdown-item" href="#">Card Number: Descending</a></li>
</ul>
<div id="totalResults"></div>
</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>
<!-- Modal nav buttons, rendered outside modal-content so they survive htmx swaps -->
<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 is:inline>
(function () {
// ── Global helpers (called from card-modal partial onclick) ───────────────
window.copyImage = async function(img) {
try {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
ctx.drawImage(img, 0, 0);
// Try modern clipboard API first (requires HTTPS + permissions)
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 {
// Fallback: copy the image URL to clipboard as text
const url = img.src;
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(url);
showCopyToast('📋 Image URL copied!', '#198754');
} else {
// Last resort: execCommand (deprecated but broadly supported)
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);
}
// ── Trigger infinite scroll sentinel ─────────────────────────────────────
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' });
}
}
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();
modal.innerHTML = html;
if (typeof htmx !== 'undefined') htmx.process(modal);
updateNavButtons(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');
}
// ── Nav button clicks ─────────────────────────────────────────────────────
document.getElementById('modalPrevBtn').addEventListener('click', navigatePrev);
document.getElementById('modalNextBtn').addEventListener('click', navigateNext);
// ── Keyboard ──────────────────────────────────────────────────────────────
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(); }
});
// ── Touch / swipe ─────────────────────────────────────────────────────────
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 });
// ── Hook into HTMX card-modal opens ──────────────────────────────────────
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');
// ── Fetch first, THEN transition ──────────────────────────────────────
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();
// Use a unique name per transition to avoid duplicate view-transition-name conflicts
const transitionName = `card-hero-${currentCardId}`;
try {
if (sourceImg) {
sourceImg.style.viewTransitionName = transitionName;
sourceImg.style.opacity = '0'; // hide original immediately after capture
}
const transition = document.startViewTransition(async () => {
// Clear source name BEFORE setting it on the destination
if (sourceImg) sourceImg.style.viewTransitionName = '';
target.innerHTML = html;
if (typeof htmx !== 'undefined') htmx.process(target);
const destImg = target.querySelector('img.card-image');
if (destImg) {
destImg.style.viewTransitionName = transitionName; // same unique name
if (!destImg.complete) {
await new Promise(resolve => {
destImg.addEventListener('load', resolve, { once: true });
destImg.addEventListener('error', resolve, { once: true });
});
}
}
});
await transition.finished;
updateNavButtons(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 = ''; // restore after transition
}
const destImg = target.querySelector('img.card-image');
if (destImg) destImg.style.viewTransitionName = '';
}
});
// ── Show/hide nav buttons with Bootstrap modal events ────────────────────
const cardModal = document.getElementById('cardModal');
cardModal.addEventListener('shown.bs.modal', () => {
updateNavButtons(cardModal);
});
cardModal.addEventListener('hidden.bs.modal', () => {
currentCardId = null;
updateNavButtons(null);
});
})();
</script>