400 lines
15 KiB
Plaintext
400 lines
15 KiB
Plaintext
---
|
|
import BackToTop from "./BackToTop.astro"
|
|
import LeftSidebarDesktop from "./LeftSidebarDesktop.astro"
|
|
---
|
|
<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">
|
|
<div id="facetContainer"></div>
|
|
</div>
|
|
</div>
|
|
<LeftSidebarDesktop />
|
|
</div>
|
|
<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);
|
|
});
|
|
|
|
// ── AdSense re-init on infinite scroll ───────────────────────────────────
|
|
document.addEventListener('htmx:afterSwap', () => {
|
|
(window.adsbygoogle = window.adsbygoogle || []).push({});
|
|
});
|
|
|
|
})();
|
|
</script> |