sliding modals, view transitions, accessibility, etc, etc

This commit is contained in:
zach
2026-03-11 15:21:43 -04:00
parent 7482cb9e9c
commit 3d46a48a7d
20 changed files with 708 additions and 519 deletions

View File

@@ -11,6 +11,9 @@ export default defineConfig({
},
}),
],
server: {
allowedHosts: true,
},
adapter: node({ mode: "standalone", checkOrigin: false }),
output: "server",
security: {

View File

@@ -35,6 +35,31 @@ html {
scroll-behavior: smooth;
}
/* --------------------------------------------------
View Transitions
-------------------------------------------------- */
@view-transition {
navigation: auto;
}
::view-transition-group(card-image) {
animation-duration: 300ms;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
::view-transition-old(card-image),
::view-transition-new(card-image) {
width: 100%;
height: 100%;
object-fit: cover;
}
/* Optional: fade everything else */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 150ms;
}
/* --------------------------------------------------
Layout
-------------------------------------------------- */
@@ -159,6 +184,33 @@ html {
}
}
.modal-nav-btn {
position: fixed;
top: 50%;
transform: translateY(-50%);
z-index: 1060; /* above modal backdrop (1050) */
background: rgba(255,255,255,0.1);
border: 1px solid rgba(255,255,255,0.2);
color: white;
border-radius: 50%;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.2s;
backdrop-filter: blur(4px);
}
.modal-nav-btn:hover { background: rgba(255,255,255,0.25); }
.modal-nav-btn.d-none { display: none !important; }
.modal-nav-prev { left: 12px; }
.modal-nav-next { right: 12px; }
@media (max-width: 768px) {
.modal-nav-btn { display: none !important; } /* use swipe on mobile */
}
/* --------------------------------------------------
Navigation Tabs & Tier Colors
-------------------------------------------------- */
@@ -218,7 +270,7 @@ $tiers: (
}
/* price-row alert left borders */
.nav-#{$name} div.alert-secondary {
.nav-#{$name} div.alert {
border-left: 3px solid $color;
}
}
@@ -521,15 +573,75 @@ $tiers: (
/* --------------------------------------------------
Swipe Animation
-------------------------------------------------- */
@keyframes swipe-feedback {
0% { transform: scale(1); }
50% { transform: scale(0.95); }
100% { transform: scale(1); }
/* Smooth the hero image morph */
::view-transition-group(card-hero) {
animation-duration: 350ms;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
/* Fade the old image out quickly so it doesn't ghost */
::view-transition-old(card-hero) {
display: none;
}
/* Fade the new image in after it's in position */
::view-transition-new(card-hero) {
animation-duration: 350ms;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
/* Suppress the default full-page crossfade so only the card morphs */
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
}
/* Sliding out (old content) */
::view-transition-old(.modal-content) {
animation: slide-out 200ms ease-in forwards;
}
/* Sliding in (new content) */
::view-transition-new(.modal-content) {
animation: slide-in 200ms ease-out forwards;
}
/* Direction-aware — set via dataset.navDirection */
#cardModal[data-nav-direction="next"]::view-transition-old(.modal-content) {
animation: slide-out-left 200ms ease-in forwards;
}
#cardModal[data-nav-direction="next"]::view-transition-new(.modal-content) {
animation: slide-in-right 200ms ease-out forwards;
}
#cardModal[data-nav-direction="prev"]::view-transition-old(.modal-content) {
animation: slide-out-right 200ms ease-in forwards;
}
#cardModal[data-nav-direction="prev"]::view-transition-new(.modal-content) {
animation: slide-in-left 200ms ease-out forwards;
}
/* The silhouette fades out while the colour image blooms in */
::view-transition-old(pokemon-reveal) {
animation: 300ms ease-in both fade-to-white;
}
::view-transition-new(pokemon-reveal) {
animation: 500ms ease-out both bloom-in;
}
@keyframes fade-to-white {
to { opacity: 0; filter: brightness(3); }
}
@keyframes bloom-in {
from { opacity: 0; filter: brightness(2) saturate(0); transform: scale(0.95); }
to { opacity: 1; filter: brightness(1) saturate(1); transform: scale(1); }
}
/* --------------------------------------------------
Input Fix (Safari)
-------------------------------------------------- */
input[type="search"]::-webkit-search-cancel-button {
-webkit-appearance: none;
height: 1rem;
@@ -539,3 +651,4 @@ input[type="search"]::-webkit-search-cancel-button {
background-size: 1rem;
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeCAYAAAA7MK6iAAAAn0lEQVR42u3UMQrDMBBEUZ9WfQqDmm22EaTyjRMHAlM5K+Y7lb0wnUZPIKHlnutOa+25Z4D++MRBX98MD1V/trSppLKHqj9TTBWKcoUqffbUcbBBEhTjBOV4ja4l4OIAZThEOV6jHO8ARXD+gPPvKMABinGOrnu6gTNUawrcQKNCAQ7QeTxORzle3+sDfjJpPCqhJh7GixZq4rHcc9l5A9qZ+WeBhgEuAAAAAElFTkSuQmCC);
}
-------------------------------------------------- */

View File

@@ -1,35 +1,43 @@
---
---
<button type="button" class="btn btn-info p-2 rounded-circle" aria-label="Back to Top" id="btn-back-to-top" onclick="dataLayer.push({'event': 'backToTop'});">
<span class="top-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M232 64C201.1 64 176 89.1 176 120L176 320.6C172.8 322.6 169.6 324.8 166.5 327.1L144 344C123.9 359.1 112 382.8 112 408L112 427.5C112 480.5 141.1 529.2 187.7 554.3L196 558.8C217 570.1 240.4 576 264.3 576L400 576C461.9 576 512 525.9 512 464L512 368C512 332.7 483.3 304 448 304C445.2 304 442.4 304.2 439.7 304.5C428.7 285.1 407.9 272 384 272C378.7 272 373.5 272.7 368.5 273.9C357.7 253.7 336.5 240 312 240C303.5 240 295.4 241.7 288 244.7L288 120C288 89.1 262.9 64 232 64zM208 120C208 106.7 218.7 96 232 96C245.3 96 256 106.7 256 120L256 277.5C256 284.6 260.6 290.8 267.4 292.8C274.2 294.8 281.4 292.2 285.4 286.3C291.2 277.6 301 272 312.1 272C327.7 272 340.7 283.2 343.5 297.9C344.5 303 347.9 307.4 352.7 309.5C357.5 311.6 363 311.3 367.5 308.6C372.3 305.7 378 304 384.1 304C398.8 304 411.3 314 415 327.6C416.2 332 419.2 335.7 423.3 337.7C427.4 339.7 432.1 339.9 436.4 338.3C440 336.9 444 336.1 448.2 336.1C465.9 336.1 480.2 350.4 480.2 368.1L480.2 464.1C480.2 508.3 444.4 544.1 400.2 544.1L264.5 544.1C246 544.1 227.7 539.5 211.4 530.7L211.4 530.7L203.1 526.2C166.6 506.6 144 468.7 144 427.5L144 408C144 392.9 151.1 378.7 163.2 369.6L176 360L176 408C176 416.8 183.2 424 192 424C200.8 424 208 416.8 208 408L208 120z"/></svg></span>
<button
type="button"
class="btn btn-info p-2 rounded-circle"
aria-label="Back to Top"
aria-hidden="true"
id="btn-back-to-top"
style="display:none"
>
<span class="top-icon">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
<path d="M232 64C201.1 64 176 89.1 176 120L176 320.6C172.8 322.6 169.6 324.8 166.5 327.1L144 344C123.9 359.1 112 382.8 112 408L112 427.5C112 480.5 141.1 529.2 187.7 554.3L196 558.8C217 570.1 240.4 576 264.3 576L400 576C461.9 576 512 525.9 512 464L512 368C512 332.7 483.3 304 448 304C445.2 304 442.4 304.2 439.7 304.5C428.7 285.1 407.9 272 384 272C378.7 272 373.5 272.7 368.5 273.9C357.7 253.7 336.5 240 312 240C303.5 240 295.4 241.7 288 244.7L288 120C288 89.1 262.9 64 232 64zM208 120C208 106.7 218.7 96 232 96C245.3 96 256 106.7 256 120L256 277.5C256 284.6 260.6 290.8 267.4 292.8C274.2 294.8 281.4 292.2 285.4 286.3C291.2 277.6 301 272 312.1 272C327.7 272 340.7 283.2 343.5 297.9C344.5 303 347.9 307.4 352.7 309.5C357.5 311.6 363 311.3 367.5 308.6C372.3 305.7 378 304 384.1 304C398.8 304 411.3 314 415 327.6C416.2 332 419.2 335.7 423.3 337.7C427.4 339.7 432.1 339.9 436.4 338.3C440 336.9 444 336.1 448.2 336.1C465.9 336.1 480.2 350.4 480.2 368.1L480.2 464.1C480.2 508.3 444.4 544.1 400.2 544.1L264.5 544.1C246 544.1 227.7 539.5 211.4 530.7L211.4 530.7L203.1 526.2C166.6 506.6 144 468.7 144 427.5L144 408C144 392.9 151.1 378.7 163.2 369.6L176 360L176 408C176 416.8 183.2 424 192 424C200.8 424 208 416.8 208 408L208 120z"/>
</svg>
</span>
</button>
<script>
//Get the button
let mybutton = document.getElementById("btn-back-to-top");
const mybutton = document.getElementById("btn-back-to-top");
// When the user scrolls down 20px from the top of the document, show the button
window.onscroll = function () {
scrollFunction();
};
function scrollFunction() {
if (
document.body.scrollTop > 20 ||
document.documentElement.scrollTop > 20
) {
mybutton.style.display = "block";
} else {
mybutton.style.display = "none";
function setButtonVisibility(visible: boolean) {
if (!mybutton) return;
mybutton.style.display = visible ? "block" : "none";
mybutton.setAttribute("aria-hidden", visible ? "false" : "true");
}
}
// When the user clicks on the button, scroll to the top of the document
mybutton.addEventListener("click", backToTop);
function backToTop() {
document.body.scrollTop = 0;
document.documentElement.scrollTop = 0;
}
function scrollFunction() {
const scrolled = document.body.scrollTop > 20 || document.documentElement.scrollTop > 20;
setButtonVisibility(scrolled);
}
function backToTop() {
dataLayer.push({ event: "backToTop" });
window.scrollTo({ top: 0, behavior: "smooth" });
}
if (mybutton) {
mybutton.addEventListener("click", backToTop);
}
window.addEventListener("scroll", scrollFunction);
</script>

View File

@@ -2,7 +2,7 @@
import BackToTop from "./BackToTop.astro"
---
<div class="row mb-4">
<div class="col-md-2 display-sm-none">
<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">
@@ -15,16 +15,256 @@ import BackToTop from "./BackToTop.astro"
</div>
</div>
<div class="col-sm-12 col-md-10 mt-0">
<div id="activeFilters" class="mb-2 d-flex align-items-center justify-content-end small"></div>
<div id="cardGrid" 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"></div>
</div>
</div>
<div class="modal fade card-modal" id="cardModal" tabindex="-1" aria-labelledby="cardModalLabel" aria-hidden="true" transition:name="">
<div class="modal-dialog modal-dialog-centered modal-fullscreen-md-down modal-xl">
<div class="modal-content">
Loading...
<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>
<BackToTop>
<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 () {
// ── 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();
try {
if (sourceImg) {
sourceImg.style.viewTransitionName = 'card-hero';
sourceImg.style.opacity = '0'; // hide original immediately after capture
}
const transition = document.startViewTransition(async () => {
target.innerHTML = html;
if (typeof htmx !== 'undefined') htmx.process(target);
const destImg = target.querySelector('img.card-image');
if (destImg) {
destImg.style.viewTransitionName = 'card-hero';
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>

View File

@@ -21,13 +21,16 @@ const energyMap = {
"Fire": fire,
"Water": water,
"Steel": steel,
"Metal": steel,
"Colorless": colorless,
"Fighting": fighting,
"Psychic": psychic,
"Electric": electric,
"Lightning": electric,
};
const svg = energyMap[energy as keyof typeof energyMap] ?? "";
if (!svg && energy) console.warn(`No energy icon found for: ${energy}`);
---
<div class="energy-icon shadow-filter" set:html={svg}></div>
<div class="energy-icon shadow-filter" role="img" aria-label={energy} set:html={svg}></div>

View File

@@ -5,6 +5,7 @@ const { edition } = Astro.props;
const editionMap = {
"1st Edition Holofoil": first,
"1st Edition": first,
};
const svg = editionMap[edition as keyof typeof editionMap] ?? "";

View File

@@ -1,15 +1,17 @@
---
import EnergyWheel from './EnergyWheel.astro';
import '/src/assets/css/main.scss';
---
<footer class="bd-footer py-4 py-md-5 mt-0 bottom-0 bg-body-tertiary">
<div class="container py-4 py-md-5 px-4 px-md-3 text-body-secondary">
<div class="row">
<div class="col-3 mb-3">
</div>
<div class="col mb-3 align-items-end">
<a class="btn btn-outline-success rounded p-2 float-end" href="/contact">Contact Us <svg class="nav-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path opacity=".25" d="M112 176L404 176C411.9 206.7 431 233 456.6 250.2L320 353.9L112 196.1L112 176zM112 256.3L305.5 403.1L320 414.1L334.5 403.1L509.2 270.6C515.3 271.5 521.6 272 528 272L528 464L112 464L112 256.3z"/><path d="M528 64C572.2 64 608 99.8 608 144C608 188.2 572.2 224 528 224C483.8 224 448 188.2 448 144C448 99.8 483.8 64 528 64zM88 128L401 128C400.3 133.2 400 138.6 400 144C400 155 401.4 165.8 404 176L112 176L112 196.1L320 353.9L456.6 250.3C472.1 260.7 489.9 267.8 509.2 270.7L334.5 403.2L320 414.2L305.5 403.2L112 256.4L112 464.1L528 464.1L528 272.1C545 272.1 561.2 268.8 576 262.8L576 512.1L64 512.1L64 128.1L88 128.1z"/></svg></a>
---
<footer class="bd-footer py-4 py-md-5 mt-0 bg-body-tertiary">
<div class="container py-4 py-md-5 px-4 px-md-3 text-body-secondary">
<div class="row justify-content-end">
<div class="col mb-3">
<a class="btn btn-outline-success rounded p-2 float-end" href="/contact">
Contact Us
<svg aria-hidden="true" class="nav-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
<path opacity=".25" d="M112 176L404 176C411.9 206.7 431 233 456.6 250.2L320 353.9L112 196.1L112 176zM112 256.3L305.5 403.1L320 414.1L334.5 403.1L509.2 270.6C515.3 271.5 521.6 272 528 272L528 464L112 464L112 256.3z"/>
<path d="M528 64C572.2 64 608 99.8 608 144C608 188.2 572.2 224 528 224C483.8 224 448 188.2 448 144C448 99.8 483.8 64 528 64zM88 128L401 128C400.3 133.2 400 138.6 400 144C400 155 401.4 165.8 404 176L112 176L112 196.1L320 353.9L456.6 250.3C472.1 260.7 489.9 267.8 509.2 270.7L334.5 403.2L320 414.2L305.5 403.2L112 256.4L112 464.1L528 464.1L528 272.1C545 272.1 561.2 268.8 576 262.8L576 512.1L64 512.1L64 128.1L88 128.1z"/>
</svg>
</a>
</div>
</div>
</div>

View File

@@ -1,11 +1,10 @@
---
import '/src/assets/css/main.scss';
export const prerender = false;
---
<nav class="navbar navbar-expand sticky-top bg-dark" data-bs-theme="dark">
<div class="container container-fluid">
<nav class="navbar navbar-expand sticky-top bg-dark" data-bs-theme="dark" aria-label="Main navigation">
<div class="container">
<a class="navbar-brand d-flex" href="/">
<span class="h3 d-none d-md-flex">Rigid's App Thing</span><span class="h3 d-md-none d-flex m-auto">RAT</span>
<span class="h3 d-none d-md-flex">Rigid's App Thing</span><span aria-hidden="true" class="h3 d-md-none d-flex m-auto">RAT</span>
</a>
<slot name="navItems"/>
<slot name="searchInput"/>

View File

@@ -1,10 +1,16 @@
---
import '/src/assets/css/main.scss';
---
<div class="navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
<li class="nav-item d-flex">
<a class="nav-link btn btn-warning rounded p-2" href="/pokemon"><span class="d-inline-block d-md-none">Cards</span> <svg class="nav-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path opacity=".4" d="M256 519.9L256 576L576 576L576 128L378.8 128C408.7 239.7 438.6 351.3 468.5 463C397.7 482 326.8 501 256 519.9z"/><path d="M43.5 113L352.6 30.2L468.6 462.9L159.5 545.7z"/></svg></a>
</li>
</ul>
</div>
<div class="navbar-collapse" id="navbarNav" aria-labelledby="navbarToggler">
<ul class="navbar-nav ms-auto">
<li class="nav-item d-flex">
<a class="nav-link btn btn-warning rounded p-2" href="/pokemon" aria-label="Cards">
<span class="d-inline-block d-md-none" aria-hidden="true">Cards</span>
<svg aria-hidden="true" class="nav-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
<path opacity=".4" d="M256 519.9L256 576L576 576L576 128L378.8 128C408.7 239.7 438.6 351.3 468.5 463C397.7 482 326.8 501 256 519.9z"/>
<path d="M43.5 113L352.6 30.2L468.6 462.9L159.5 545.7z"/>
</svg>
</a>
</li>
</ul>
</div>

View File

@@ -1,7 +1,6 @@
---
import '/src/assets/css/main.scss';
---
---
<header class="header-top w-100">
<div class="header-wrap">
<div class="header-content">

View File

@@ -45,6 +45,7 @@ const rarityMap = {
};
const svg = rarityMap[rarity as keyof typeof rarityMap] ?? "";
if (!svg && rarity) console.warn(`No rarity icon found for: ${rarity}`);
---
<div class="rarity-icon shadow-filter" set:html={svg}></div>
<div class="rarity-icon shadow-filter" role="img" aria-label={rarity} set:html={svg}></div>

View File

@@ -27,12 +27,12 @@ import { Show } from '@clerk/astro/components'
<Show when="signed-in">
<form class="d-flex ms-2" role="search" id="searchform" hx-post="/partials/cards" hx-target="#cardGrid" hx-trigger="load, submit" hx-vals='{"start":"0"}' hx-on--after-request="afterUpdate()" hx-on--before-request="beforeSearch()">
<a class="btn btn-secondary btn-lg me-2" data-bs-toggle="offcanvas" href="#filterBar" role="button" aria-controls="filterBar"><span class="d-block d-md-none filter-icon mt-1"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path opacity=".4" d="M96 160L283.3 347.3C286.3 350.3 288 354.4 288 358.6L288 480L352 544L352 358.6C352 354.4 353.7 350.3 356.7 347.3L544 160L96 160z"/><path d="M66.4 147.8C71.4 135.8 83.1 128 96 128L544 128C556.9 128 568.6 135.8 573.6 147.8C578.6 159.8 575.8 173.5 566.7 182.7L384 365.3L384 544C384 556.9 376.2 568.6 364.2 573.6C352.2 578.6 338.5 575.8 329.3 566.7L265.3 502.7C259.3 496.7 255.9 488.6 255.9 480.1L256 365.3L73.4 182.6C64.2 173.5 61.5 159.7 66.4 147.8zM544 160L96 160L283.3 347.3C286.3 350.3 288 354.4 288 358.6L288 480L352 544L352 358.6C352 354.4 353.7 350.3 356.7 347.3L544 160z"/></svg></span><span class="d-none d-md-block">Filters</span></a>
<a class="btn btn-secondary btn-lg me-2" data-bs-toggle="offcanvas" href="#filterBar" role="button" aria-controls="filterBar" aria-label="filter"><span class="d-block d-md-none filter-icon mt-1"><svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path opacity=".4" d="M96 160L283.3 347.3C286.3 350.3 288 354.4 288 358.6L288 480L352 544L352 358.6C352 354.4 353.7 350.3 356.7 347.3L544 160L96 160z"/><path d="M66.4 147.8C71.4 135.8 83.1 128 96 128L544 128C556.9 128 568.6 135.8 573.6 147.8C578.6 159.8 575.8 173.5 566.7 182.7L384 365.3L384 544C384 556.9 376.2 568.6 364.2 573.6C352.2 578.6 338.5 575.8 329.3 566.7L265.3 502.7C259.3 496.7 255.9 488.6 255.9 480.1L256 365.3L73.4 182.6C64.2 173.5 61.5 159.7 66.4 147.8zM544 160L96 160L283.3 347.3C286.3 350.3 288 354.4 288 358.6L288 480L352 544L352 358.6C352 354.4 353.7 350.3 356.7 347.3L544 160z"/></svg></span><span class="d-none d-md-block">Filters</span></a>
<div class="input-group">
<input type="hidden" name="start" id="start" value="0" />
<input type="search" name="q" class="form-control form-control-lg" placeholder="Search cards..." />
<button type="submit" class="btn btn-danger btn-lg border border-danger-subtle border-start-0" value="" onclick="const q = document.querySelector('[name=q]').value; dataLayer.push({ event: 'view_search_results', search_term: q });">
<svg class="search-button d-block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M432 272C432 183.6 360.4 112 272 112C183.6 112 112 183.6 112 272C112 360.4 183.6 432 272 432C360.4 432 432 360.4 432 272zM401.1 435.1C365.7 463.2 320.8 480 272 480C157.1 480 64 386.9 64 272C64 157.1 157.1 64 272 64C386.9 64 480 157.1 480 272C480 320.8 463.2 365.7 435.1 401.1L569 535C578.4 544.4 578.4 558.1 569 567.5C559.6 575.9 544.4 575.9 536 567.5L401.1 435.1z"/></svg>
<button type="submit" class="btn btn-danger btn-lg border border-danger-subtle border-start-0" aria-label="search" value="" onclick="const q = this.closest('form').querySelector('[name=q]').value; dataLayer.push({ event: 'view_search_results', search_term: q });">
<svg aria-hidden="true" class="search-button d-block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M432 272C432 183.6 360.4 112 272 112C183.6 112 112 183.6 112 272C112 360.4 183.6 432 272 432C360.4 432 432 360.4 432 272zM401.1 435.1C365.7 463.2 320.8 480 272 480C157.1 480 64 386.9 64 272C64 157.1 157.1 64 272 64C386.9 64 480 157.1 480 272C480 320.8 463.2 365.7 435.1 401.1L569 535C578.4 544.4 578.4 558.1 569 567.5C559.6 575.9 544.4 575.9 536 567.5L401.1 435.1z"/></svg>
</button>
</div>
</form>

View File

@@ -123,6 +123,7 @@ import mega_evolutions from "/src/svg/set/mega_evolutions.svg?raw";
import phantasmal_flames from "/src/svg/set/phantasmal_flames.svg?raw";
import destined_rivals from "/src/svg/set/destined_rivals.svg?raw";
import surging_sparks from "/src/svg/set/surging_sparks.svg?raw";
import team_rocket from "/src/svg/set/team_rocket.svg?raw";
const { set } = Astro.props;
@@ -130,7 +131,7 @@ const setMap = {
"JU": jungle,
"FO": fossil,
"B2": base_set_2,
"TR": battle_styles,
"TR": team_rocket,
"G1": gym_heroes,
"G2": gym_challenge,
"SI": southern_islands,
@@ -254,6 +255,7 @@ const setMap = {
};
const svg = setMap[set as keyof typeof setMap] ?? "";
if (!svg && set) console.warn(`No set icon found for: ${set}`);
---
<div class="set-icon shadow-filter" set:html={svg}></div>
<div class="set-icon shadow-filter" role="img" aria-label={set} set:html={svg}></div>

View File

@@ -1,13 +1,11 @@
---
import '/src/assets/css/main.scss';
const { title } = Astro.props;
---
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<script is:inline>
window.dataLayer = window.dataLayer || [];
</script>
<!-- Google Tag Manager -->
<script is:inline>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
@@ -17,10 +15,10 @@ import '/src/assets/css/main.scss';
<!-- End Google Tag Manager -->
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="htmx-config" content='{"historyCacheSize": 50}'></meta>
<meta name="htmx-config" content='{"historyCacheSize": 50}'/>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" href="/favicon.ico" />
<title>Rigid's App Thing</title>
<title>{title}</title>
</head>
<body>
<!-- Google Tag Manager (noscript) -->
@@ -40,7 +38,7 @@ import '/src/assets/css/main.scss';
</div>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
<script src="../assets/js/main.js"></script>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
</body>
</html>

View File

@@ -1,10 +1,10 @@
---
export const prerender = false;
import Layout from '../layouts/Main.astro';
import NavItems from '../components/NavItems.astro';
import NavBar from '../components/NavBar.astro';
export const prerender = false;
import pokedexList from '../data/pokedex.json';
import Footer from '../components/Footer.astro';
import pokedexList from '../data/pokedex.json';
const searchParams = Astro.url.searchParams;
const query = searchParams.get('q') || '*';
@@ -21,13 +21,13 @@ const pokemon = pokedexList.find(p => p["#"] === randomNumber);
// If not found (rare), fallback
const pokemonName = pokemon?.Name || "Unknown Pokémon";
---
<Layout>
<Layout title="404 - Page Not Found">
<NavBar slot="navbar">
<NavItems slot="navItems" />
</NavBar>
<div class="row mb-4" slot="page">
<div class="col-12 col-md-6">
<h1 class="mb-4">404 - Page Not Found</h1>
<h1 class="mb-4">404<br/>Page Not Found</h1>
<h4>Sorry, the page you are looking for does not exist.</h4>
<p class="copy-big my-4">
Return to the <a href="/">home page</a> or search for another <a href="/pokemon">Pokémon</a>.
@@ -40,14 +40,20 @@ const pokemonName = pokemon?.Name || "Unknown Pokémon";
</div>
<div class="p-0 ratio ratio-1x1 position-relative overflow-hidden d-flex justify-items-center">
<img class="whos-that-pokemon position-absolute h-100" src="/404/lines.gif">
<img class="whos-that-pokemon position-absolute h-100" src="/404/lines.gif" alt="" />
<div class="d-flex flex-col-reverse flex-lg-row">
<div class="">
<img class="w-100 starburst top-0 bottom-0 left-0 right-0" src="/404/glow.png">
<div class="d-flex flex-column-reverse flex-lg-row">
<div>
<img class="w-100 starburst top-0 bottom-0 left-0 right-0" src="/404/glow.png" alt="" />
<!-- ✨ Name is placed in a data attribute for later use -->
<img class="m-auto position-absolute w-50 top-0 left-25 bottom-10 right-0 d-block img-fluid masked-image top-50 start-50 translate-middle" src={pokedexImage} alt={pokemonName} data-name={pokemonName} onclick="dataLayer.push({'event': '404reveal','pokemonName': this.getAttribute('data-name')});"/>
<img
class="m-auto position-absolute w-75 top-0 left-25 bottom-10 right-0 d-block img-fluid masked-image top-50 start-50 translate-middle"
src={pokedexImage}
alt={pokemonName}
data-name={pokemonName}
role="button"
tabindex="0"
/>
</div>
</div>
</div>
@@ -57,16 +63,44 @@ const pokemonName = pokemon?.Name || "Unknown Pokémon";
<h3 id="pokemon-name" class="opacity-0 transition-opacity">???</h3>
</div>
</div>
<script>
const img = document.querySelector('.masked-image');
const nameEl = document.querySelector('#pokemon-name');
<script>
const img = document.querySelector('.masked-image') as HTMLImageElement | null;
const nameEl = document.querySelector('#pokemon-name');
img?.addEventListener('click', () => {
img.classList.remove('masked-image');
nameEl.textContent = img.dataset.name || "Unknown Pokémon";
nameEl.classList.remove('opacity-0');
function revealPokemon() {
if (!img || !nameEl) return;
const doReveal = () => {
img.classList.remove('masked-image');
nameEl.textContent = img.dataset.name || "Unknown Pokémon";
nameEl.classList.remove('opacity-0');
dataLayer.push({ event: '404reveal', pokemonName: img.dataset.name });
};
if (!document.startViewTransition) {
doReveal();
return;
}
img.style.viewTransitionName = 'pokemon-reveal';
document.startViewTransition(() => {
doReveal();
}).finished.then(() => {
img.style.viewTransitionName = '';
});
</script>
}
img?.addEventListener('click', revealPokemon);
img?.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
revealPokemon();
}
});
</script>
</div>
<Footer slot="footer" />
</Layout>

View File

@@ -1,42 +1,92 @@
---
export const prerender = false;
import Layout from '../layouts/Main.astro';
import NavItems from '../components/NavItems.astro';
import NavBar from '../components/NavBar.astro';
import Footer from '../components/Footer.astro';
export const prerender = false;
---
<Layout>
<Layout title="Contact Us">
<NavBar slot="navbar">
<NavItems slot="navItems" />
</NavBar>
<div class="row mb-4" slot="page">
<h1>Contact Us</h1>
<div class="col-12">
<h1>Contact Us</h1>
</div>
<div class="col-12 col-md-8 col-lg-6">
<form action="https://docs.google.com/forms/u/0/d/e/1FAIpQLSc4F7VZjZ6ImWnNRqzMLyAWnyGQdEC3Nr2xtbzugewky239kg/formResponse" method="POST" id="contactForm">
<!-- Name input -->
<div class="mb-3">
<label for="name" class="form-label">Full Name</label>
<input type="text" class="form-control" id="name" name="entry.563494744" required>
</div>
<form action="https://docs.google.com/forms/u/0/d/e/1FAIpQLSc4F7VZjZ6ImWnNRqzMLyAWnyGQdEC3Nr2xtbzugewky239kg/formResponse" method="POST" id="contactForm" target="hidden-iframe">
<!-- Email address input -->
<div class="mb-3">
<label for="email" class="form-label">Email address</label>
<input type="email" class="form-control" id="email" name="entry.577942868" aria-describedby="emailHelp" required>
<div id="emailHelp" class="form-text">We'll never share your email with anyone else.</div>
</div>
<!-- Honeypot field to deter spam -->
<div style="display:none" aria-hidden="true">
<label for="honeypot">Leave this field blank</label>
<input type="text" id="honeypot" name="honeypot" tabindex="-1" autocomplete="off" />
</div>
<!-- Message textarea -->
<div class="mb-3">
<label for="message" class="form-label">Message</label>
<textarea class="form-control" id="message" name="entry.1640055664" rows="4" required></textarea>
</div>
<!-- Name input -->
<div class="mb-3">
<label for="name" class="form-label">Full Name</label>
<input type="text" class="form-control" id="name" name="entry.563494744" required />
</div>
<!-- Submit button -->
<button type="submit" class="btn btn-light">Submit</button>
<!-- Email address input -->
<div class="mb-3">
<label for="email" class="form-label">Email address</label>
<input type="email" class="form-control" id="email" name="entry.577942868" aria-describedby="emailHelp" required />
<div id="emailHelp" class="form-text">We'll never share your email with anyone else.</div>
</div>
<!-- Message textarea -->
<div class="mb-3">
<label for="message" class="form-label">Message</label>
<textarea class="form-control" id="message" name="entry.1640055664" rows="4" required></textarea>
</div>
<!-- Submit button -->
<button type="submit" class="btn btn-light" id="submitBtn">Submit</button>
</form>
<!-- Hidden iframe absorbs the Google Forms redirect -->
<iframe name="hidden-iframe" style="display:none" aria-hidden="true"></iframe>
<!-- Success message (hidden until submission) -->
<div id="successMsg" class="alert alert-success mt-3 d-none" role="alert">
Thanks for reaching out! We'll get back to you soon.
</div>
</div>
</div>
<Footer slot="footer" />
</Layout>
<script>
const form = document.getElementById('contactForm') as HTMLFormElement | null;
const submitBtn = document.getElementById('submitBtn') as HTMLButtonElement | null;
const successMsg = document.getElementById('successMsg');
const honeypot = document.getElementById('honeypot') as HTMLInputElement | null;
const iframe = document.querySelector('iframe[name="hidden-iframe"]') as HTMLIFrameElement | null;
form?.addEventListener('submit', (e) => {
// Honeypot check — bail silently if filled in by a bot
if (honeypot?.value) {
e.preventDefault();
return;
}
if (!submitBtn || !successMsg) return;
submitBtn.disabled = true;
submitBtn.textContent = 'Sending...';
});
// iframe load fires after Google Forms redirects into it — treat as success
iframe?.addEventListener('load', () => {
if (!form || !submitBtn || !successMsg) return;
// Ignore the initial empty load before any submission
if (!submitBtn.disabled) return;
form.reset();
form.classList.add('d-none');
successMsg.classList.remove('d-none');
dataLayer.push({ event: 'contact_form_submit' });
});
</script>

View File

@@ -1,22 +1,24 @@
---
export const prerender = false;
import Layout from '../layouts/Main.astro';
import NavItems from '../components/NavItems.astro';
import NavBar from '../components/NavBar.astro';
import Footer from '../components/Footer.astro';
export const prerender = false;
import { Show, SignInButton, SignUpButton, SignOutButton } from '@clerk/astro/components'
---
<Layout>
<Layout title="Rigid's App Thing">
<NavBar slot="navbar">
<NavItems slot="navItems" />
</NavBar>
<div class="row mb-4" slot="page">
<h1>Rigid's App Thing</h1>
<h5 class="text-secondary">(working title)</h5>
<div class="col-12">
<h1>Rigid's App Thing</h1>
<p class="text-secondary">(working title)</p>
</div>
<div class="col-12 col-md-6 mb-2">
<h4 class="mt-3">Welcome!</h4>
<h2 class="mt-3">Welcome!</h2>
<p class="mt-2">
You've been selected to participate in the closed beta! This single page web application is meant to elevate condition/variant data for the Pokemon TCG. In future iterations, we will add vendor inventory/collection management features, as well.</p>
You've been selected to participate in the closed beta! This single page web application is meant to elevate condition/variant data for the Pokemon TCG. In future iterations, we will add vendor inventory/collection management features, as well.
</p>
<p class="my-2">
After the closed beta is complete, the app will move into a more open beta. Feel free to play "Who's that Pokémon?" with the random Pokémon generator <a href="/404">here</a>. Refresh the page to see a new Pokémon!
@@ -25,30 +27,21 @@ import { Show, SignInButton, SignUpButton, SignOutButton } from '@clerk/astro/co
<a href="/pokemon" class="btn btn-warning mt-2">Take me to the cards</a>
</Show>
</div>
<div class="col-12 col-md-6 d-flex flex-row gap-5 justify-content-end">
<div>
<Show when="signed-out">
<!-- Using Bootstrap btn classes -->
<SignInButton asChild mode="modal">
<button class="btn btn-success">
Sign In
</button>
</SignInButton>
<SignUpButton asChild mode="modal">
<button class="btn btn-dark">
Request Access
</button>
</SignUpButton>
</Show>
</div>
<div>
<Show when="signed-in">
<SignOutButton asChild mode="modal">
<button class="btn btn-danger">
Sign Out
</button>
</SignOutButton>
</Show>
<div class="col-12 col-md-6 d-flex justify-content-end align-items-start mt-3">
<div class="d-flex gap-3">
<Show when="signed-out">
<SignInButton asChild mode="modal">
<button class="btn btn-success">Sign In</button>
</SignInButton>
<SignUpButton asChild mode="modal">
<button class="btn btn-dark">Request Access</button>
</SignUpButton>
</Show>
<Show when="signed-in">
<SignOutButton asChild>
<button class="btn btn-danger">Sign Out</button>
</SignOutButton>
</Show>
</div>
</div>
</div>

View File

@@ -73,31 +73,27 @@ const conditionOrder = ["Near Mint", "Lightly Played", "Moderately Played", "Hea
const conditionAttributes = (price: any) => {
const volatility = (() => {
const current = price?.marketPrice;
const market = price?.marketPrice;
const low = price?.lowestPrice;
const high = price?.highestPrice;
const median = price?.medianPrice;
if (current === null || low === null || high === null) return "Indeterminate";
if (market == null || low == null || high == null || Number(market) === 0) {
return "Indeterminate";
}
const range = Number(high) - Number(low);
if (range <= 0) return "Low";
const spreadPct = (Number(high) - Number(low)) / Number(market) * 100;
const position = (Number(current) - Number(low)) / range;
if (position > 0.76) return "High";
if (position > 0.49) return "Medium";
if (spreadPct >= 81) return "High";
if (spreadPct >= 59) return "Medium";
return "Low";
})();
// Updated logic:
// ❗ If High / Medium / Low → never "alert-secondary"
// ❗ If Indeterminate → "alert-secondary"
const volatilityClass = (() => {
switch (volatility) {
case "High": return "alert-danger";
case "Medium": return "alert-warning";
case "Low": return "alert-success";
default: return "alert-secondary"; // Only for Indeterminate
case "High": return "alert-danger";
case "Medium": return "alert-warning";
case "Low": return "alert-success";
default: return "alert-dark"; // Indeterminate
}
})();
@@ -115,6 +111,10 @@ const conditionAttributes = (price: any) => {
const ebaySearchUrl = (card: any) => {
return `https://www.ebay.com/sch/i.html?_nkw=${encodeURIComponent(card?.productUrlName)}+${encodeURIComponent(card?.set?.setUrlName)}+${encodeURIComponent(card?.number)}&LH_Sold=1&Graded=No&_dcat=183454`;
};
const altSearchUrl = (card: any) => {
return `https://alt.xyz/browse?query=${encodeURIComponent(card?.productUrlName)}+${encodeURIComponent(card?.set?.setUrlName)}+${encodeURIComponent(card?.number)}&sortBy=newest_first`;
};
---
<div class="modal-dialog modal-dialog-centered modal-fullscreen-md-down modal-xl">
@@ -126,16 +126,6 @@ const ebaySearchUrl = (card: any) => {
<div class="text-light col-auto">{card?.variant}</div>
</div>
<div class="d-flex gap-2 align-items-center">
<button type="button" class="btn btn-sm btn-outline-secondary card-nav-prev d-none" title="Previous card (← or swipe right)" aria-label="Previous card">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</button>
<button type="button" class="btn btn-sm btn-outline-secondary card-nav-next d-none" title="Next card (→ or swipe left)" aria-label="Next card">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
<button type="button" class="btn-close" aria-label="Close" data-bs-dismiss="modal"></button>
</div>
</div>
@@ -178,30 +168,57 @@ const ebaySearchUrl = (card: any) => {
<div class={`tab-pane fade ${attributes?.label} ${attributes?.class}`} id={`${attributes?.label}`} role="tabpanel" tabindex="0">
<div class="d-block gap-1 d-lg-flex">
<div class="d-flex flex-row flex-lg-column gap-1 col-12 col-lg-2 mb-1">
<div class="alert alert-secondary rounded p-2 flex-fill mb-1">
<h6>Market Price</h6>
<p class="pb-0">${price.marketPrice}</p>
<div class="alert alert-dark rounded p-2 flex-fill d-flex flex-column mb-1">
<h6 class="mb-auto">Market Price</h6>
<p class="mb-0 mt-1">${price.marketPrice}</p>
</div>
<div class="alert alert-secondary rounded p-2 flex-fill mb-1">
<h6>Lowest Price</h6>
<p class="pb-0">${price.lowestPrice}</p>
<div class="alert alert-dark rounded p-2 flex-fill d-flex flex-column mb-1">
<h6 class="mb-auto">Lowest Price</h6>
<p class="mb-0 mt-1">${price.lowestPrice}</p>
</div>
<div class="alert alert-secondary rounded p-2 flex-fill mb-1">
<h6>Highest Price</h6>
<p class="pb-0">${price.highestPrice}</p>
<div class="alert alert-dark rounded p-2 flex-fill d-flex flex-column mb-1">
<h6 class="mb-auto">Highest Price</h6>
<p class="mb-0 mt-1">${price.highestPrice}</p>
</div>
<div class={`alert alert-secondary rounded p-2 flex-fill mb-1 ${attributes?.volatilityClass}`}>
<h6>Volatility</h6>
<p class="pb-0 small">{attributes?.volatility}</p>
<div class={`alert alert-secondary rounded p-2 flex-fill d-flex flex-column mb-1 ${attributes?.volatilityClass}`}>
<h6 class="mb-auto">Volatility</h6>
<p class="mb-0 mt-1">{attributes?.volatility}</p>
</div>
</div>
<div class="d-flex flex-column gap-1 col-12 col-lg-10 mb-0 me-2 clearfix">
<div class="alert alert-secondary rounded p-2 mb-1">
<h6>Latest Sales</h6>
</div>
<div class="alert alert-secondary rounded p-2 mb-1">
<h6>Placeholder for graph</h6>
<div class="alert alert-dark rounded p-2 mb-1 table-responsive">
<h6>Latest Verified Sales</h6>
<table class="table table-sm mb-0">
<caption class="small">Filtered to remove mismatched language variants</caption>
<thead class="table-dark">
<tr>
<th scope="col">Date</th>
<th scope="col">Title</th>
<th scope="col">Price</th>
</tr>
</thead>
<tbody>
<tr>
<td></td>
<td></td>
<td></td>
</tr>
</tbody>
</table>
</div>
<div class="alert alert-dark rounded p-2 mb-1">
<h6>Market Price History</h6>
<div class="position-relative" style="height: 200px;">
<canvas id={`priceChart-${price.priceId}`} class="price-history-chart" data-card-id={card?.cardId} data-condition={price.condition}></canvas>
</div>
<div class="btn-group btn-group-sm d-flex flex-row gap-1 justify-content-end" role="group" aria-label="Time range">
<button type="button" class="btn btn-dark price-range-btn active" data-range="1m">1M</button>
<button type="button" class="btn btn-dark price-range-btn" data-range="3m">3M</button>
<button type="button" class="btn btn-dark price-range-btn" data-range="6m">6M</button>
<button type="button" class="btn btn-dark price-range-btn" data-range="1y">1Y</button>
<button type="button" class="btn btn-dark price-range-btn" data-range="all">All</button>
</div>
</div>
</div>
</div>
</div>
@@ -214,9 +231,10 @@ const ebaySearchUrl = (card: any) => {
</div>
</div>
</div>
<div class="col-sm-12 col-md-2 mt-0 mt-md-5">
<a class="btn btn-outline-light mb-2 w-100" href={`https://www.tcgplayer.com/product/${card?.productId}`} target="_blank" onclick="dataLayer.push({'event': 'tcgplayerClick', 'tcgplayerUrl': this.getAttribute('href')});"><img src="/vendors/tcgplayer.webp"> <span class="d-none d-lg-inline">TCGPlayer</span></a>
<a class="btn btn-outline-light mb-2 w-100" href={`${ebaySearchUrl(card)}`} target="_blank" onclick="dataLayer.push({'event': 'ebayClick', 'ebayUrl': this.getAttribute('href')});"><span set:html={ebay} /></a>
<div class="col-sm-12 col-md-2 mt-0 mt-md-5 d-flex flex-row flex-md-column">
<a class="btn btn-dark mb-2 w-100 p-2" href={`https://www.tcgplayer.com/product/${card?.productId}`} target="_blank" onclick="dataLayer.push({'event': 'tcgplayerClick', 'tcgplayerUrl': this.getAttribute('href')});"><img src="/vendors/tcgplayer.webp"> <span class="d-none d-lg-inline">TCGPlayer</span></a>
<a class="btn btn-dark mb-2 w-100 p-2" href={`${ebaySearchUrl(card)}`} target="_blank" onclick="dataLayer.push({'event': 'ebayClick', 'ebayUrl': this.getAttribute('href')});"><span set:html={ebay} /></a>
<a class="btn btn-dark mb-2 w-100 p-2" href={`${altSearchUrl(card)}`} target="_blank" onclick="dataLayer.push({'event': 'altClick', 'altUrl': this.getAttribute('href')});"><svg width="48" height="20.16" viewBox="0 0 48 20" fill="none"><path d="M14.2761 19.9996H18.5308L11.6934 0.0712891H7.76953L14.2761 19.9996Z" fill="#ffffff"></path><path d="M6.17778 19.9986H6.14536L3.19643 11.2305L0 19.9988L6.17768 19.9989L6.17778 19.9986Z" fill="#ffffff"></path><path d="M24.7842 0H20.6759V19.9661H34.3427V16.5426H24.7842V0Z" fill="#ffffff"></path><path d="M41.6644 3.42355H47.4981V0H31.5033V3.42355H37.5561V19.9661H41.6644V3.42355Z" fill="#ffffff"></path></svg></a>
</div>
</div>
<div class="text-end my-0"><small class="text-body-tertiary">Prices last changed: {timeAgo(calculatedAt)}</small></div>
@@ -227,324 +245,42 @@ const ebaySearchUrl = (card: any) => {
</div>
<script is:inline>
async function copyImage(img) {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
try {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
ctx.drawImage(img, 0, 0);
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
// draw the real image pixels
ctx.drawImage(img, 0, 0);
// convert to blob
canvas.toBlob(async (blob) => {
await navigator.clipboard.write([
new ClipboardItem({ "image/png": blob })
]);
console.log("Copied image via canvas.");
});
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');
} catch (err) {
console.error('Failed:', err);
showCopyToast('❌ Copy failed', '#dc3545');
}
}
// ===== Card Navigation System =====
// Only declare class if it hasn't been declared yet (prevents HTMX reload errors)
if (typeof window.CardNavigator === 'undefined') {
window.CardNavigator = class CardNavigator {
constructor() {
this.touchStartX = 0;
this.touchEndX = 0;
this.swipeThreshold = 50; // minimum distance in pixels for a swipe
this.loadingMoreCards = false;
this.init();
}
init() {
this.setupEventListeners();
this.updateNavigationButtons();
this.setupScrollObserver();
}
setupScrollObserver() {
// Listen for when new cards are added to the grid
const observer = new MutationObserver(() => {
this.loadingMoreCards = false;
});
const cardGrid = document.getElementById('cardGrid');
if (cardGrid) {
observer.observe(cardGrid, {
childList: true,
subtree: false
});
}
}
setupEventListeners() {
const modal = document.getElementById('cardModal');
if (!modal) return;
// Keyboard navigation
document.addEventListener('keydown', (e) => this.handleKeydown(e));
// Touch/Swipe navigation
modal.addEventListener('touchstart', (e) => this.handleTouchStart(e), false);
modal.addEventListener('touchend', (e) => this.handleTouchEnd(e), false);
// Button navigation
const prevBtn = document.querySelector('.card-nav-prev');
const nextBtn = document.querySelector('.card-nav-next');
if (prevBtn) prevBtn.addEventListener('click', () => this.navigatePrev());
if (nextBtn) nextBtn.addEventListener('click', () => this.navigateNext());
}
handleKeydown(e) {
// Only navigate if the modal is visible
const modal = document.getElementById('cardModal');
if (!modal || !modal.classList.contains('show')) return;
if (e.key === 'ArrowLeft') {
e.preventDefault();
this.navigatePrev();
} else if (e.key === 'ArrowRight') {
e.preventDefault();
this.navigateNext();
}
}
handleTouchStart(e) {
this.touchStartX = e.changedTouches[0].screenX;
}
handleTouchEnd(e) {
this.touchEndX = e.changedTouches[0].screenX;
this.handleSwipe();
}
handleSwipe() {
const diff = this.touchStartX - this.touchEndX;
// Swipe left = show next card
if (diff > this.swipeThreshold) {
this.navigateNext();
}
// Swipe right = show previous card
else if (diff < -this.swipeThreshold) {
this.navigatePrev();
}
}
getVisibleCards() {
// Get all card triggers from the current grid
return Array.from(document.querySelectorAll('[data-card-id]'));
}
getCurrentCardElement() {
const modal = document.getElementById('cardModal');
if (!modal) return null;
const currentCardId = modal.querySelector('.modal-content')?.getAttribute('data-card-id');
return currentCardId ? document.querySelector(`[data-card-id="${currentCardId}"]`) : null;
}
navigatePrev() {
const cards = this.getVisibleCards();
const currentCard = this.getCurrentCardElement();
if (!cards.length || !currentCard) return;
const currentIndex = cards.indexOf(currentCard);
if (currentIndex <= 0) return; // Already at the first card
const prevCard = cards[currentIndex - 1];
this.loadCard(prevCard);
}
navigateNext() {
const cards = this.getVisibleCards();
const currentCard = this.getCurrentCardElement();
if (!cards.length || !currentCard) return;
const currentIndex = cards.indexOf(currentCard);
if (currentIndex >= cards.length - 1) {
// At the last card, try to load more cards via infinite scroll
this.triggerInfiniteScroll();
return;
}
const nextCard = cards[currentIndex + 1];
this.loadCard(nextCard);
}
triggerInfiniteScroll() {
if (this.loadingMoreCards) return; // Already loading
this.loadingMoreCards = true;
// Find the infinite scroll trigger element (the "Loading..." div with hx-trigger="revealed")
const scrollTrigger = document.querySelector('[hx-trigger="revealed"]');
if (scrollTrigger) {
// Trigger HTMX to load more cards
if (typeof htmx !== 'undefined') {
htmx.trigger(scrollTrigger, 'revealed');
} else {
// Fallback: manually call the endpoint
const endpoint = scrollTrigger.getAttribute('hx-post') || scrollTrigger.getAttribute('hx-get');
if (endpoint) {
const formData = new FormData(document.getElementById('searchform'));
const currentCards = this.getVisibleCards();
const start = (currentCards.length - 1) * 20; // Approximate
formData.append('start', start.toString());
fetch(endpoint, {
method: 'POST',
body: formData
})
.then(r => r.text())
.then(html => {
const grid = document.getElementById('cardGrid');
if (grid) {
grid.insertAdjacentHTML('beforeend', html);
this.waitForNewCards();
}
});
}
}
// Wait for new cards to be added and then navigate
this.waitForNewCards();
} else {
this.loadingMoreCards = false;
}
}
waitForNewCards() {
// Wait up to 3 seconds for new cards to load
let attempts = 0;
const checkInterval = setInterval(() => {
attempts++;
const cards = this.getVisibleCards();
const currentCardId = document.querySelector('#cardModal .modal-content')?.getAttribute('data-card-id');
const currentIndex = cards.findIndex(c => c.getAttribute('data-card-id') === currentCardId);
// If we have more cards than before, navigate to the next one
if (currentIndex >= 0 && currentIndex < cards.length - 1) {
clearInterval(checkInterval);
const nextCard = cards[currentIndex + 1];
this.loadingMoreCards = false;
this.loadCard(nextCard);
} else if (attempts > 30) { // 30 * 100ms = 3 seconds
clearInterval(checkInterval);
this.loadingMoreCards = false;
this.updateNavigationButtons();
}
}, 100);
}
loadCard(cardElement) {
const hxGet = cardElement.getAttribute('hx-get');
if (!hxGet) return;
// Check if HTMX is available, if not use fetch
if (typeof htmx !== 'undefined') {
// Use HTMX to load the card
htmx.ajax('GET', hxGet, {
target: '#cardModal',
swap: 'innerHTML',
onLoad: () => {
this.updateNavigationButtons();
// Trigger any analytics event if needed
const cardTitle = cardElement.querySelector('#cardImage')?.getAttribute('alt') || 'Unknown Card';
if (typeof dataLayer !== 'undefined') {
dataLayer.push({
'event': 'virtualPageview',
'pageUrl': hxGet,
'pageTitle': cardTitle,
'previousUrl': '/pokemon'
});
}
}
});
} else {
// Fallback to native fetch if HTMX not available
fetch(hxGet)
.then(response => response.text())
.then(html => {
const modalContent = document.querySelector('#cardModal .modal-dialog');
if (modalContent) {
modalContent.innerHTML = html;
this.updateNavigationButtons();
// Trigger any analytics event if needed
const cardTitle = cardElement.querySelector('#cardImage')?.getAttribute('alt') || 'Unknown Card';
if (typeof dataLayer !== 'undefined') {
dataLayer.push({
'event': 'virtualPageview',
'pageUrl': hxGet,
'pageTitle': cardTitle,
'previousUrl': '/pokemon'
});
}
}
})
.catch(error => console.error('Error loading card:', error));
}
}
updateNavigationButtons() {
const cards = this.getVisibleCards();
const currentCard = this.getCurrentCardElement();
const currentIndex = cards.indexOf(currentCard);
const prevBtn = document.querySelector('.card-nav-prev');
const nextBtn = document.querySelector('.card-nav-next');
if (prevBtn) {
prevBtn.disabled = currentIndex <= 0;
prevBtn.style.opacity = currentIndex <= 0 ? '0.5' : '1';
}
if (nextBtn) {
nextBtn.disabled = currentIndex >= cards.length - 1;
nextBtn.style.opacity = currentIndex >= cards.length - 1 ? '0.5' : '1';
}
}
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);
}
} // End of: if (typeof window.CardNavigator === 'undefined')
// Initialize the card navigator when the modal is shown (only setup once)
if (!window.cardNavigatorInitialized) {
window.cardNavigatorInitialized = true;
const modalElement = document.getElementById('cardModal');
if (modalElement) {
modalElement.addEventListener('shown.bs.modal', () => {
if (!window.cardNavigator) {
window.cardNavigator = new window.CardNavigator();
} else {
window.cardNavigator.updateNavigationButtons();
}
});
}
// Also initialize on first load in case the modal is already visible
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
// Wait a bit for HTMX to load
setTimeout(() => {
if (!window.cardNavigator) {
window.cardNavigator = new window.CardNavigator();
}
}, 100);
});
} else {
// Wait a bit for HTMX to load if already loaded
setTimeout(() => {
if (!window.cardNavigator) {
window.cardNavigator = new window.CardNavigator();
}
}, 100);
}
}
</script>

View File

@@ -138,6 +138,7 @@ const facets = searchResults.results.slice(1).map((result: any) => {
});
---
{(start === 0) &&
<div id="facetContainer" hx-swap-oob="true">
@@ -164,8 +165,10 @@ const facets = searchResults.results.slice(1).map((result: any) => {
</div>
))}
</div>
<div id="activeFilters" class="mb-2 d-flex align-items-center justify-content-end small" hx-swap-oob="true">
<div id="totalResults d-none" class="ms-5 text-secondary" hx-swap-oob="true">
{totalHits} {totalHits === 1 ? ' result' : ' results'}
</div>
<div id="activeFilters" class="d-flex align-items-center small ms-auto" hx-swap-oob="true">
{(Object.entries(filters).length > 0) &&
<span class="me-1">Filtered by:</span>
<ul class="list-group list-group-horizontal">
@@ -179,7 +182,6 @@ const facets = searchResults.results.slice(1).map((result: any) => {
}
</div>
<script define:vars={{ totalHits, filters, facets }} is:inline>
// Filter the facet values to make things like Set easier to find

View File

@@ -1,15 +1,14 @@
---
export const prerender = false;
import Layout from '../layouts/Main.astro';
import Search from '../components/Search.astro';
import CardGrid from "../components/CardGrid.astro";
import NavBar from '../components/NavBar.astro';
export const prerender = false;
---
<Layout>
<Layout title="Card Search">
<NavBar slot="navbar">
<Search slot="searchInput" />
</NavBar>
</NavBar>
<CardGrid slot="page" />
</Layout>