1 Commits

Author SHA1 Message Date
Zach Harding
6299c07b87 current working holographic pattern for desktop/mobile (needs work still) 2026-03-22 19:29:08 -04:00
20 changed files with 826 additions and 740 deletions

3
.gitignore vendored
View File

@@ -26,9 +26,6 @@ pnpm-debug.log*
# imges from tcgplayer # imges from tcgplayer
public/cards/* public/cards/*
# static assets
/static/
# anything test # anything test
test.* test.*

View File

@@ -12,7 +12,7 @@ async function findMissingImages() {
.where(sql`${schema.tcgcards.sealed} = false`); .where(sql`${schema.tcgcards.sealed} = false`);
const missingImages: string[] = []; const missingImages: string[] = [];
for (const card of cards) { for (const card of cards) {
const imagePath = path.join(process.cwd(), 'static', 'cards', `${card.productId}.jpg`); const imagePath = path.join(process.cwd(), 'public', 'cards', `${card.productId}.jpg`);
try { try {
await fs.access(imagePath); await fs.access(imagePath);
} catch (err) { } catch (err) {

View File

@@ -54,7 +54,6 @@ export const createCardCollection = async () => {
{ name: 'productLineName', type: 'string', facet: true }, { name: 'productLineName', type: 'string', facet: true },
{ name: 'rarityName', type: 'string', facet: true }, { name: 'rarityName', type: 'string', facet: true },
{ name: 'setName', type: 'string', facet: true }, { name: 'setName', type: 'string', facet: true },
{ name: 'setCode', type: 'string' },
{ name: 'cardType', type: 'string', facet: true }, { name: 'cardType', type: 'string', facet: true },
{ name: 'energyType', type: 'string', facet: true }, { name: 'energyType', type: 'string', facet: true },
{ name: 'number', type: 'string', sort: true }, { name: 'number', type: 'string', sort: true },
@@ -95,15 +94,7 @@ export const upsertCardCollection = async (db:DBInstance) => {
with: { set: true, tcgdata: true, prices: true }, with: { set: true, tcgdata: true, prices: true },
}); });
await client.collections('cards').documents().import(pokemon.map(card => { await client.collections('cards').documents().import(pokemon.map(card => {
// Use the NM SKU price matching the card's variant (kept fresh by syncPrices) const marketPrice = card.tcgdata?.marketPrice ? DollarToInt(card.tcgdata.marketPrice) : null;
// Fall back to any NM sku, then to tcgdata price
const nmSku = card.prices.find(p => p.condition === 'Near Mint' && p.variant === card.variant)
?? card.prices.find(p => p.condition === 'Near Mint');
const marketPrice = nmSku?.marketPrice
? DollarToInt(nmSku.marketPrice)
: card.tcgdata?.marketPrice
? DollarToInt(card.tcgdata.marketPrice)
: null;
return { return {
id: card.cardId.toString(), id: card.cardId.toString(),
@@ -114,13 +105,12 @@ export const upsertCardCollection = async (db:DBInstance) => {
productLineName: card.productLineName, productLineName: card.productLineName,
rarityName: card.rarityName, rarityName: card.rarityName,
setName: card.set?.setName || "", setName: card.set?.setName || "",
setCode: card.set?.setCode || "",
cardType: card.cardType || "", cardType: card.cardType || "",
energyType: card.energyType || "", energyType: card.energyType || "",
number: card.number, number: card.number,
Artist: card.artist || "", Artist: card.artist || "",
sealed: card.sealed, sealed: card.sealed,
content: [card.productName, card.productLineName, card.set?.setName || "", card.set?.setCode || "", card.number, card.rarityName, card.artist || ""].join(' '), content: [card.productName, card.productLineName, card.set?.setName || "", card.number, card.rarityName, card.artist || ""].join(' '),
releaseDate: card.tcgdata?.releaseDate ? Math.floor(new Date(card.tcgdata.releaseDate).getTime() / 1000) : 0, releaseDate: card.tcgdata?.releaseDate ? Math.floor(new Date(card.tcgdata.releaseDate).getTime() / 1000) : 0,
...(marketPrice !== null && { marketPrice }), ...(marketPrice !== null && { marketPrice }),
sku_id: card.prices.map(price => price.skuId.toString()) sku_id: card.prices.map(price => price.skuId.toString())

View File

@@ -242,7 +242,7 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
} }
// get image if it doesn't already exist // get image if it doesn't already exist
const imagePath = path.join(process.cwd(), 'static', 'cards', `${item.productId}.jpg`); const imagePath = path.join(process.cwd(), 'public', 'cards', `${item.productId}.jpg`);
if (!await helper.FileExists(imagePath)) { if (!await helper.FileExists(imagePath)) {
const imageResponse = await fetch(`https://tcgplayer-cdn.tcgplayer.com/product/${item.productId}_in_1000x1000.jpg`); const imageResponse = await fetch(`https://tcgplayer-cdn.tcgplayer.com/product/${item.productId}_in_1000x1000.jpg`);
if (imageResponse.ok) { if (imageResponse.ok) {
@@ -280,6 +280,6 @@ else {
await helper.UpdateVariants(db); await helper.UpdateVariants(db);
// index the card updates // index the card updates
await helper.upsertCardCollection(db); helper.upsertCardCollection(db);
await ClosePool(); await ClosePool();

View File

@@ -154,7 +154,6 @@ const updateLatestSales = async (updatedCards: Set<number>) => {
const start = Date.now(); const start = Date.now();
const updatedCards = await syncPrices(); const updatedCards = await syncPrices();
await helper.upsertSkuCollection(db); await helper.upsertSkuCollection(db);
await helper.upsertCardCollection(db);
//console.log(updatedCards); //console.log(updatedCards);
//console.log(updatedCards.size); //console.log(updatedCards.size);
//await updateLatestSales(updatedCards); //await updateLatestSales(updatedCards);

View File

@@ -40,8 +40,8 @@
// ============================================================================= // =============================================================================
// Shared texture/asset references // Shared texture/asset references
$grain: url('/public/holofoils/grain.webp'); $grain: url('/holofoils/grain.webp');
$glitter: url('/public/holofoils/glitter.png'); $glitter: url('/holofoils/glitter.png');
$glittersize: 25%; $glittersize: 25%;
@@ -190,7 +190,7 @@ $glittersize: 25%;
/// No-mask fallback for cards using the illusion foil pattern. /// No-mask fallback for cards using the illusion foil pattern.
@mixin no-mask-illusion { @mixin no-mask-illusion {
--mask: none; --mask: none;
--foil: url('/public/holofoils/illusion.png'); --foil: url('/holofoils/illusion.png');
--imgsize: 33%; --imgsize: 33%;
-webkit-mask-image: var(--mask); -webkit-mask-image: var(--mask);
mask-image: var(--mask); mask-image: var(--mask);
@@ -919,7 +919,7 @@ $glittersize: 25%;
--space: 4%; --space: 4%;
clip-path: var(--clip); clip-path: var(--clip);
background-image: background-image:
url('/public/holofoils/cosmos-bottom.png'), url('/holofoils/cosmos-bottom.png'),
$cosmos-stripe, $cosmos-stripe,
radial-gradient( radial-gradient(
farthest-corner circle at var(--pointer-x) var(--pointer-y), farthest-corner circle at var(--pointer-x) var(--pointer-y),
@@ -939,7 +939,7 @@ $glittersize: 25%;
&::before { &::before {
content: ''; content: '';
z-index: 2; z-index: 2;
background-image: url('/public/holofoils/cosmos-middle-trans.png'), $cosmos-stripe; background-image: url('/holofoils/cosmos-middle-trans.png'), $cosmos-stripe;
background-blend-mode: lighten, multiply; background-blend-mode: lighten, multiply;
background-position: background-position:
var(--cosmosbg, center center), var(--cosmosbg, center center),
@@ -953,7 +953,7 @@ $glittersize: 25%;
&::after { &::after {
content: ''; content: '';
z-index: 3; z-index: 3;
background-image: url('/public/holofoils/cosmos-top-trans.png'), $cosmos-stripe; background-image: url('/holofoils/cosmos-top-trans.png'), $cosmos-stripe;
background-blend-mode: multiply, multiply; background-blend-mode: multiply, multiply;
background-position: background-position:
var(--cosmosbg, center center), var(--cosmosbg, center center),
@@ -1081,7 +1081,7 @@ $glittersize: 25%;
.card__shine, .card__shine,
.card__shine::after { .card__shine::after {
--mask: none; --mask: none;
--foil: url('/public/holofoils/trainerbg.png'); --foil: url('/holofoils/trainerbg.png');
--imgsize: 25% auto; --imgsize: 25% auto;
} }
.card__shine::after { background-blend-mode: difference; } .card__shine::after { background-blend-mode: difference; }
@@ -1205,7 +1205,7 @@ $glittersize: 25%;
} }
&:not(.masked) .card__shine { &:not(.masked) .card__shine {
--foil: url('/public/holofoils/illusion-mask.png'); --foil: url('/holofoils/illusion-mask.png');
--imgsize: 33%; --imgsize: 33%;
} }
} }
@@ -1377,7 +1377,7 @@ $glittersize: 25%;
.card__glare { @extend %secret-rare-glare; } .card__glare { @extend %secret-rare-glare; }
&:not(.masked) .card__shine { &:not(.masked) .card__shine {
--foil: url('/public/holofoils/geometric.png'); --foil: url('/holofoils/geometric.png');
--imgsize: 33%; --imgsize: 33%;
filter: brightness(calc((var(--pointer-from-center) * 0.3) + 0.2)) contrast(2) saturate(0.75); filter: brightness(calc((var(--pointer-from-center) * 0.3) + 0.2)) contrast(2) saturate(0.75);
} }
@@ -1648,7 +1648,7 @@ $glittersize: 25%;
} }
&:not(.masked) .card__shine { &:not(.masked) .card__shine {
--foil: url('/public/holofoils/illusion-mask.png'); --foil: url('/holofoils/illusion-mask.png');
--imgsize: 33%; --imgsize: 33%;
} }
} }
@@ -1719,7 +1719,7 @@ $glittersize: 25%;
&:not(.masked) { &:not(.masked) {
.card__shine, .card__shine,
.card__shine::after { .card__shine::after {
--foil: url('/public/holofoils/trainerbg.png'); --foil: url('/holofoils/trainerbg.png');
--imgsize: 20%; --imgsize: 20%;
background-blend-mode: color-burn, hue, hard-light; background-blend-mode: color-burn, hue, hard-light;
filter: brightness(calc((var(--pointer-from-center) * 0.05) + .6)) contrast(1.5) saturate(1.2); filter: brightness(calc((var(--pointer-from-center) * 0.05) + .6)) contrast(1.5) saturate(1.2);
@@ -1828,7 +1828,7 @@ $glittersize: 25%;
&:not(.masked) { &:not(.masked) {
.card__shine { .card__shine {
--foil: url('/public/holofoils/geometric.png'); --foil: url('/holofoils/geometric.png');
--imgsize: 33%; --imgsize: 33%;
filter: brightness(calc((var(--pointer-from-center) * 0.3) + 0.2)) contrast(2) saturate(0.75); filter: brightness(calc((var(--pointer-from-center) * 0.3) + 0.2)) contrast(2) saturate(0.75);
} }
@@ -2027,7 +2027,7 @@ $glittersize: 25%;
.card__shine, .card__shine,
.card__shine::after { .card__shine::after {
--mask: none; --mask: none;
--foil: url('/public/holofoils/vmaxbg.jpg'); --foil: url('/holofoils/vmaxbg.jpg');
--imgsize: 60% 30%; --imgsize: 60% 30%;
} }
} }
@@ -2102,7 +2102,7 @@ $glittersize: 25%;
.card__shine, .card__shine,
.card__shine::after { .card__shine::after {
--mask: none; --mask: none;
--foil: url('/public/holofoils/ancient.png'); --foil: url('/holofoils/ancient.png');
--imgsize: 18% 15%; --imgsize: 18% 15%;
background-blend-mode: exclusion, hue, hard-light; background-blend-mode: exclusion, hue, hard-light;
filter: brightness(calc((var(--pointer-from-center) * .25) + .35)) contrast(1.8) saturate(1.75); filter: brightness(calc((var(--pointer-from-center) * .25) + .35)) contrast(1.8) saturate(1.75);

View File

@@ -1,19 +1,40 @@
// ============================================================================= // =============================================================================
// HOLOFOIL INTEGRATION // HOLOFOIL INTEGRATION
// _holofoil-integration.scss // =============================================================================
//
// Three effect zones, determined by rarity and variant:
//
// NONE — no effect at all
// variant = Normal (or no recognised rarity/variant)
//
// INVERSE — effect on borders only (everything except the art window)
// variant = Reverse Holofoil
// rarity = Prism Rare
//
// ART WINDOW — effect clipped to the artwork area only
// rarity = Rare | Amazing Rare | Classic Collection | Holo Rare
// variant = Holofoil | 1st Edition Holofoil
//
// FULL CARD — effect over the entire card
// rarity = Ultra Rare | Character Rare | Illustration Rare |
// Special Illustration Rare | Double Rare | Hyper Rare |
// Mega Rare | Mega Attack Rare | ACE Spec Rare | ACE Rare |
// Art Rare | Special Art Rare | Black White Rare |
// Character Super Rare | Mega Ultra Rare | Rare BREAK |
// Secret Rare | Shiny Holo Rare | Shiny Rare |
// Shiny Secret Rare | Shiny Ultra Rare
//
// ============================================================================= // =============================================================================
@import "card";
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// 1. WRAPPER NORMALISATION // 1. CSS CUSTOM PROPERTIES — set on every wrapper element
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
%holofoil-wrapper-base { .image-grow,
--card-aspect: 0.718; .card-image-wrap {
--card-radius: 4.55% / 3.5%;
// Pointer tracking — updated by holofoil-init.js on mousemove
--pointer-x: 50%; --pointer-x: 50%;
--pointer-y: 50%; --pointer-y: 50%;
--background-x: 50%; --background-x: 50%;
@@ -21,29 +42,23 @@
--pointer-from-center: 0; --pointer-from-center: 0;
--pointer-from-top: 0.5; --pointer-from-top: 0.5;
--pointer-from-left: 0.5; --pointer-from-left: 0.5;
--card-scale: 1;
--card-opacity: 0; --card-opacity: 0;
--card-scale: 1;
--grain: url('/public/holofoils/grain.webp'); // Card geometry — matches Bootstrap's rounded-4 (--bs-border-radius-xl)
--glitter: url('/public/holofoils/glitter.png'); --card-radius: var(--bs-border-radius-xl, 0.375rem);
--glittersize: 25%;
--space: 5%;
--angle: 133deg;
--imgsize: cover;
--red: #f80e35; // Art window clip — original poke-holo values, correct for standard TCG card scans
--yellow: #eedf10; // inset(top right bottom left): top=9.85%, sides=8%, bottom=52.85% (art bottom at 47.15%)
--green: #21e985; --clip-art: inset(9.85% 8% 52.85% 8%);
--blue: #0dbde9;
--violet: #c929f1;
--clip: inset(9.85% 8% 52.85% 8%);
--clip-invert: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%, 0 47.15%, 91.5% 47.15%, 91.5% 9.85%, 8% 9.85%, 8% 47.15%, 0 50%);
--clip-stage: polygon(91.5% 9.85%, 57% 9.85%, 54% 12%, 17% 12%, 16% 14%, 12% 16%, 8% 16%, 8% 47.15%, 92% 47.15%);
--clip-stage-invert: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%, 0 47.15%, 91.5% 47.15%, 91.5% 9.85%, 57% 9.85%, 54% 12%, 17% 12%, 16% 14%, 12% 16%, 8% 16%, 8% 47.15%, 0 50%);
--clip-trainer: inset(14.5% 8.5% 48.2% 8.5%);
--clip-borders: inset(2.8% 4% round 2.55% / 1.5%);
// Sunpillar palette
--sunpillar-1: hsl(2, 100%, 73%);
--sunpillar-2: hsl(53, 100%, 69%);
--sunpillar-3: hsl(93, 100%, 69%);
--sunpillar-4: hsl(176, 100%, 76%);
--sunpillar-5: hsl(228, 100%, 74%);
--sunpillar-6: hsl(283, 100%, 73%);
--sunpillar-clr-1: var(--sunpillar-1); --sunpillar-clr-1: var(--sunpillar-1);
--sunpillar-clr-2: var(--sunpillar-2); --sunpillar-clr-2: var(--sunpillar-2);
--sunpillar-clr-3: var(--sunpillar-3); --sunpillar-clr-3: var(--sunpillar-3);
@@ -51,40 +66,76 @@
--sunpillar-clr-5: var(--sunpillar-5); --sunpillar-clr-5: var(--sunpillar-5);
--sunpillar-clr-6: var(--sunpillar-6); --sunpillar-clr-6: var(--sunpillar-6);
// NOTE: no overflow:hidden here -- that would clip the lift/scale transform // Colour tokens
// on .image-grow. Overflow is handled by the child .holo-shine/.holo-glare. --red: #f80e35;
position: relative; --yellow: #eedf10;
isolation: isolate; --green: #21e985;
border-radius: var(--card-radius); --blue: #0dbde9;
} --violet: #c929f1;
%holofoil-energy-glows { // Glow
--card-glow: hsl(175, 100%, 90%);
// Texture assets
--grain: url('/holofoils/grain.webp');
--glitter: url('/holofoils/glitter.png');
--glittersize: 25%;
--foil: none;
// Energy glow overrides
&[data-energy="Water"] { --card-glow: hsl(192, 97%, 60%); } &[data-energy="Water"] { --card-glow: hsl(192, 97%, 60%); }
&[data-energy="Fire"] { --card-glow: hsl(9, 81%, 59%); } &[data-energy="Fire"] { --card-glow: hsl(9, 81%, 59%); }
&[data-energy="Grass"] { --card-glow: hsl(96, 81%, 65%); } &[data-energy="Grass"] { --card-glow: hsl(96, 81%, 65%); }
&[data-energy="Lightning"] { --card-glow: hsl(54, 87%, 63%); } &[data-energy="Lightning"] { --card-glow: hsl(54, 87%, 63%); }
&[data-energy="Psychic"] { --card-glow: hsl(281, 62%, 58%); } &[data-energy="Psychic"] { --card-glow: hsl(281, 62%, 58%); }
&[data-energy="Fighting"] { --card-glow: rgb(145, 90, 39); } &[data-energy="Fighting"] { --card-glow: rgb(145, 90, 39); }
&[data-energy="Darkness"] { --card-glow: hsl(189, 77%, 27%); } &[data-energy="Darkness"] { --card-glow: hsl(189, 77%, 27%); }
&[data-energy="Metal"] { --card-glow: hsl(184, 20%, 70%); } &[data-energy="Metal"] { --card-glow: hsl(184, 20%, 70%); }
&[data-energy="Dragon"] { --card-glow: hsl(51, 60%, 35%); } &[data-energy="Dragon"] { --card-glow: hsl(51, 60%, 35%); }
&[data-energy="Fairy"] { --card-glow: hsl(323, 100%, 89%); } &[data-energy="Fairy"] { --card-glow: hsl(323, 100%, 89%); }
// Hover activates opacity; JS updates pointer vars
&:hover,
&[data-holo-active] { --card-opacity: 0.2; }
display: block; // ensure wrapper is a block-level containing block
position: relative;
isolation: isolate;
border-radius: var(--card-radius);
} }
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// 2. SHINE + GLARE CHILD DIVS // 2. HOLO-SHINE AND HOLO-GLARE BASE STRUCTURE
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
%shine-base { .holo-shine,
.holo-glare {
pointer-events: none; pointer-events: none;
display: block;
position: absolute; position: absolute;
inset: 0; inset: 0;
border-radius: var(--card-radius); border-radius: var(--card-radius);
overflow: hidden; // clipping lives here, not on the parent // NO overflow:hidden — it interferes with clip-path on the element itself
z-index: 3;
will-change: transform, opacity, background-image, background-size, will-change: transform, opacity, background-image, background-size,
background-position, background-blend-mode, filter; background-position, background-blend-mode, filter;
}
// The img inside has mb-2 but the wrapper already has the right size from
// aspect-ratio on .card-image — zero the margin so img fills wrapper flush.
.image-grow > img,
.card-image-wrap > img {
display: block;
margin-bottom: 0 !important;
width: 100% !important;
height: 100% !important;
object-fit: cover;
}
.holo-shine {
z-index: 3;
mix-blend-mode: color-dodge;
opacity: var(--card-opacity);
&::before, &::before,
&::after { &::after {
@@ -92,97 +143,380 @@
position: absolute; position: absolute;
inset: 0; inset: 0;
border-radius: var(--card-radius); border-radius: var(--card-radius);
// Sunpillar palette shift for ::before depth layer
--sunpillar-clr-1: var(--sunpillar-5);
--sunpillar-clr-2: var(--sunpillar-6);
--sunpillar-clr-3: var(--sunpillar-1);
--sunpillar-clr-4: var(--sunpillar-2);
--sunpillar-clr-5: var(--sunpillar-3);
--sunpillar-clr-6: var(--sunpillar-4);
}
&::after {
// Second palette shift for uppermost pseudo layer
--sunpillar-clr-1: var(--sunpillar-6);
--sunpillar-clr-2: var(--sunpillar-1);
--sunpillar-clr-3: var(--sunpillar-2);
--sunpillar-clr-4: var(--sunpillar-3);
--sunpillar-clr-5: var(--sunpillar-4);
--sunpillar-clr-6: var(--sunpillar-5);
} }
} }
%glare-base { .holo-glare {
pointer-events: none;
position: absolute;
inset: 0;
border-radius: var(--card-radius);
z-index: 4; z-index: 4;
transform: translateZ(0); mix-blend-mode: overlay;
overflow: hidden; opacity: var(--card-opacity);
will-change: transform, opacity, background-image, background-size, background-image: radial-gradient(
background-position, background-blend-mode, filter; farthest-corner circle at var(--pointer-x) var(--pointer-y),
} hsla(0, 0%, 100%, 0.8) 10%,
hsla(0, 0%, 100%, 0.65) 20%,
hsla(0, 0%, 0%, 0.5) 90%
);
// Grain texture on ::before — soft-light blend adds physical substrate feel
// ----------------------------------------------------------------------------- &::before {
// 3. MODES content: '';
// ----------------------------------------------------------------------------- position: absolute;
inset: 0;
// -- 3a. GRID ----------------------------------------------------------------- background-image: var(--grain);
// No idle animation. Effect is invisible until hover. background-size: 33%;
background-repeat: repeat;
.image-grow, mix-blend-mode: soft-light;
.card-image-wrap { opacity: 0.15;
@extend %holofoil-wrapper-base; }
@extend %holofoil-energy-glows;
// Glitter texture on ::after — overlay blend adds sparkle points
// No effect if the image fell back to default.jpg &::after {
&[data-default="true"] { content: '';
.holo-shine, position: absolute;
.holo-glare { display: none !important; } inset: 0;
background-image: var(--glitter);
background-size: var(--glittersize) var(--glittersize);
background-repeat: repeat;
background-position:
calc(50% - ((5px * 2) * var(--pointer-from-left)) + 5px)
calc(50% - ((5px * 2) * var(--pointer-from-top)) + 5px);
mix-blend-mode: overlay;
opacity: calc(var(--card-opacity) * 0.6);
} }
.holo-shine { @extend %shine-base; }
.holo-glare { @extend %glare-base; }
} }
// -- 3b. GRID HOVER ----------------------------------------------------------- // -----------------------------------------------------------------------------
// The existing main.scss .image-grow:hover handles lift + scale. // 3. ZONE HELPERS — reusable effect mixin
// We layer the holo effect on top without overriding transform or transition. // The standard prismatic effect, applied at different clip regions below.
// -----------------------------------------------------------------------------
.image-grow:hover, // Standard shine background (used by ART WINDOW and FULL CARD zones)
.image-grow[data-holo-active] { @mixin prismatic-shine {
--card-opacity: 0.45; background-image:
var(--grain),
repeating-linear-gradient(110deg,
var(--violet), var(--blue), var(--green), var(--yellow), var(--red),
var(--violet), var(--blue), var(--green), var(--yellow), var(--red),
var(--violet), var(--blue), var(--green), var(--yellow), var(--red)
);
background-position:
center center,
calc(((50% - var(--background-x)) * 2.6) + 50%)
calc(((50% - var(--background-y)) * 3.5) + 50%);
background-size: 33%, 400% 400%;
background-repeat: repeat, no-repeat;
background-blend-mode: soft-light, normal;
filter: brightness(.8) contrast(.85) saturate(.75);
}
@mixin prismatic-glare {
opacity: calc(var(--card-opacity) * 0.4);
filter: brightness(0.8) contrast(1.5);
} }
// -- 3c. MODAL ---------------------------------------------------------------- // -----------------------------------------------------------------------------
// Sweeps once per minute. Peaks at 0.35. // 4. ZONE 0 — NORMAL: no effect
// Pointer tracking bumps opacity to 0.45 while hovering. // -----------------------------------------------------------------------------
@keyframes holo-modal-pulse { .image-grow[data-variant="Normal" i],
0% { .card-image-wrap[data-variant="Normal" i] {
--card-opacity: 0; .holo-shine,
.holo-glare { display: none !important; }
}
// -----------------------------------------------------------------------------
// 5. ZONE 1 — ART WINDOW EFFECT
// rarity: Rare, Amazing Rare, Classic Collection, Holo Rare
// variant: Holofoil, 1st Edition Holofoil
// -----------------------------------------------------------------------------
.image-grow[data-rarity="Rare" i]:not([data-variant="Reverse Holofoil" i]):not([data-variant="Normal" i]),
.card-image-wrap[data-rarity="Rare" i]:not([data-variant="Reverse Holofoil" i]):not([data-variant="Normal" i]),
.image-grow[data-rarity="Amazing Rare" i]:not([data-variant="Reverse Holofoil" i]),
.card-image-wrap[data-rarity="Amazing Rare" i]:not([data-variant="Reverse Holofoil" i]),
.image-grow[data-rarity="Classic Collection" i]:not([data-variant="Reverse Holofoil" i]),
.card-image-wrap[data-rarity="Classic Collection" i]:not([data-variant="Reverse Holofoil" i]),
.image-grow[data-rarity="Holo Rare" i]:not([data-variant="Reverse Holofoil" i]),
.card-image-wrap[data-rarity="Holo Rare" i]:not([data-variant="Reverse Holofoil" i]),
.image-grow[data-variant="Holofoil" i]:not([data-variant="Reverse Holofoil" i]),
.card-image-wrap[data-variant="Holofoil" i]:not([data-variant="Reverse Holofoil" i]),
.image-grow[data-variant="1st Edition Holofoil" i],
.card-image-wrap[data-variant="1st Edition Holofoil" i] {
.holo-shine {
clip-path: var(--clip-art);
@include prismatic-shine;
}
.holo-glare {
clip-path: var(--clip-art);
@include prismatic-glare;
}
}
// -----------------------------------------------------------------------------
// 5b. ZONE 1 BORDER ADDITION — Holofoil + 1st Edition Holofoil
//
// Real holofoil cards have the foil stamp on both the art window AND the card
// border. The element carries the art window clip; ::before carries the border
// clip via the same zero-width tunnel polygon as Zone 3.
// ::before inherits background-image/size/position from the parent via inherit.
// -----------------------------------------------------------------------------
.image-grow[data-variant="Holofoil" i]:not([data-variant="Reverse Holofoil" i]),
.card-image-wrap[data-variant="Holofoil" i]:not([data-variant="Reverse Holofoil" i]),
.image-grow[data-variant="1st Edition Holofoil" i],
.card-image-wrap[data-variant="1st Edition Holofoil" i] {
.holo-shine {
&::before {
background-image: inherit;
background-size: inherit;
background-position: inherit;
background-repeat: inherit;
background-blend-mode: inherit;
filter: inherit;
mix-blend-mode: color-dodge;
clip-path: polygon(
0% 0%, 0% 100%, 100% 100%, 100% 0%, 0% 0%,
8% 9.85%, 92% 9.85%, 92% 47.15%, 8% 47.15%, 8% 9.85%
);
}
}
.holo-glare {
&::before {
clip-path: polygon(
0% 0%, 0% 100%, 100% 100%, 100% 0%, 0% 0%,
8% 9.85%, 92% 9.85%, 92% 47.15%, 8% 47.15%, 8% 9.85%
);
}
}
}
// -----------------------------------------------------------------------------
// 6. ZONE 2 — FULL CARD EFFECT
// -----------------------------------------------------------------------------
.image-grow[data-rarity="Ultra Rare" i]:not([data-variant="Reverse Holofoil" i]),
.card-image-wrap[data-rarity="Ultra Rare" i]:not([data-variant="Reverse Holofoil" i]),
.image-grow[data-rarity="Character Rare" i]:not([data-variant="Reverse Holofoil" i]),
.card-image-wrap[data-rarity="Character Rare" i]:not([data-variant="Reverse Holofoil" i]),
.image-grow[data-rarity="Illustration Rare" i]:not([data-variant="Reverse Holofoil" i]),
.card-image-wrap[data-rarity="Illustration Rare" i]:not([data-variant="Reverse Holofoil" i]),
.image-grow[data-rarity="Special Illustration Rare" i]:not([data-variant="Reverse Holofoil" i]),
.card-image-wrap[data-rarity="Special Illustration Rare" i]:not([data-variant="Reverse Holofoil" i]),
.image-grow[data-rarity="Double Rare" i]:not([data-variant="Reverse Holofoil" i]),
.card-image-wrap[data-rarity="Double Rare" i]:not([data-variant="Reverse Holofoil" i]),
.image-grow[data-rarity="Hyper Rare" i]:not([data-variant="Reverse Holofoil" i]),
.card-image-wrap[data-rarity="Hyper Rare" i]:not([data-variant="Reverse Holofoil" i]),
.image-grow[data-rarity="Mega Rare" i]:not([data-variant="Reverse Holofoil" i]),
.card-image-wrap[data-rarity="Mega Rare" i]:not([data-variant="Reverse Holofoil" i]),
.image-grow[data-rarity="Mega Attack Rare" i]:not([data-variant="Reverse Holofoil" i]),
.card-image-wrap[data-rarity="Mega Attack Rare" i]:not([data-variant="Reverse Holofoil" i]),
.image-grow[data-rarity="ACE Spec Rare" i]:not([data-variant="Reverse Holofoil" i]),
.card-image-wrap[data-rarity="ACE Spec Rare" i]:not([data-variant="Reverse Holofoil" i]),
.image-grow[data-rarity="ACE Rare" i]:not([data-variant="Reverse Holofoil" i]),
.card-image-wrap[data-rarity="ACE Rare" i]:not([data-variant="Reverse Holofoil" i]),
.image-grow[data-rarity="Art Rare" i]:not([data-variant="Reverse Holofoil" i]),
.card-image-wrap[data-rarity="Art Rare" i]:not([data-variant="Reverse Holofoil" i]),
.image-grow[data-rarity="Special Art Rare" i]:not([data-variant="Reverse Holofoil" i]),
.card-image-wrap[data-rarity="Special Art Rare" i]:not([data-variant="Reverse Holofoil" i]),
.image-grow[data-rarity="Black White Rare" i]:not([data-variant="Reverse Holofoil" i]),
.card-image-wrap[data-rarity="Black White Rare" i]:not([data-variant="Reverse Holofoil" i]),
.image-grow[data-rarity="Character Super Rare" i]:not([data-variant="Reverse Holofoil" i]),
.card-image-wrap[data-rarity="Character Super Rare" i]:not([data-variant="Reverse Holofoil" i]),
.image-grow[data-rarity="Mega Ultra Rare" i]:not([data-variant="Reverse Holofoil" i]),
.card-image-wrap[data-rarity="Mega Ultra Rare" i]:not([data-variant="Reverse Holofoil" i]),
.image-grow[data-rarity="Rare BREAK" i]:not([data-variant="Reverse Holofoil" i]),
.card-image-wrap[data-rarity="Rare BREAK" i]:not([data-variant="Reverse Holofoil" i]),
.image-grow[data-rarity="Secret Rare" i]:not([data-variant="Reverse Holofoil" i]),
.card-image-wrap[data-rarity="Secret Rare" i]:not([data-variant="Reverse Holofoil" i]),
.image-grow[data-rarity="Shiny Holo Rare" i]:not([data-variant="Reverse Holofoil" i]),
.card-image-wrap[data-rarity="Shiny Holo Rare" i]:not([data-variant="Reverse Holofoil" i]),
.image-grow[data-rarity="Shiny Rare" i]:not([data-variant="Reverse Holofoil" i]),
.card-image-wrap[data-rarity="Shiny Rare" i]:not([data-variant="Reverse Holofoil" i]),
.image-grow[data-rarity="Shiny Secret Rare" i]:not([data-variant="Reverse Holofoil" i]),
.card-image-wrap[data-rarity="Shiny Secret Rare" i]:not([data-variant="Reverse Holofoil" i]),
.image-grow[data-rarity="Shiny Ultra Rare" i]:not([data-variant="Reverse Holofoil" i]),
.card-image-wrap[data-rarity="Shiny Ultra Rare" i]:not([data-variant="Reverse Holofoil" i]) {
.holo-shine {
clip-path: none;
@include prismatic-shine;
}
.holo-glare {
clip-path: none;
@include prismatic-glare;
}
}
// -----------------------------------------------------------------------------
// 7. ZONE 3 — INVERSE (borders only): Reverse Holofoil + Prism Rare
//
// Applies the effect to everything EXCEPT the art window.
// Uses the "zero-width tunnel" technique from css-tricks.com/cutting-inner-part-element-using-clip-path/
// Outer rectangle drawn anticlockwise, closes back to 0% 0%, then the inner
// art window rectangle is drawn clockwise — nonzero winding treats the inner
// shape as a hole, leaving the art window transparent.
//
// Outer (anticlockwise): 0 0 → 0 100% → 100% 100% → 100% 0 → 0 0
// Inner art window (clockwise): 8% 9.85% → 92% 9.85% → 92% 47.15% → 8% 47.15%
// -----------------------------------------------------------------------------
.image-grow[data-variant="Reverse Holofoil" i],
.card-image-wrap[data-variant="Reverse Holofoil" i],
.image-grow[data-rarity="Prism Rare" i],
.card-image-wrap[data-rarity="Prism Rare" i] {
// Energy colour tint — multiply blend darkens the card toward --card-glow.
// z-index 2 puts it above the card image (z-index 1) but below holo-shine (3).
// Opacity tied to --card-opacity so it appears/disappears with the hover effect.
&::after {
content: '';
position: absolute;
inset: 0;
border-radius: var(--card-radius);
background: var(--card-glow);
mix-blend-mode: multiply;
opacity: calc(var(--card-opacity) * 0.5);
z-index: 2;
pointer-events: none;
clip-path: polygon(
0% 0%, 0% 100%, 100% 100%, 100% 0%, 0% 0%,
8% 9.85%, 92% 9.85%, 92% 47.15%, 8% 47.15%, 8% 9.85%
);
}
.holo-shine {
// Energy-aware gradient — weaves --card-glow (set per data-energy on the
// wrapper) into the prismatic colour sequence so each energy type gets a
// tinted shimmer: Grass = green, Fire = orange, Water = cyan, etc.
background-image:
var(--grain),
repeating-linear-gradient(110deg,
var(--card-glow),
var(--blue),
var(--card-glow),
var(--green),
var(--yellow),
var(--card-glow),
var(--red),
var(--violet),
var(--card-glow)
);
background-position:
center center,
calc(((50% - var(--background-x)) * 2.6) + 50%)
calc(((50% - var(--background-y)) * 3.5) + 50%);
background-size: 33%, 400% 400%;
background-repeat: repeat, no-repeat;
background-blend-mode: soft-light, normal;
filter: brightness(1.0) contrast(1.0) saturate(1.4);
clip-path: polygon(
0% 0%, 0% 100%, 100% 100%, 100% 0%, 0% 0%,
8% 9.85%, 92% 9.85%, 92% 47.15%, 8% 47.15%, 8% 9.85%
) !important;
}
.holo-glare {
@include prismatic-glare;
// Hot-spot tinted with energy colour to match the shine treatment
background-image: radial-gradient(
farthest-corner circle at var(--pointer-x) var(--pointer-y),
var(--card-glow) 0%,
hsla(0, 0%, 100%, 0.3) 20%,
hsla(0, 0%, 0%, 0.5) 90%
);
clip-path: polygon(
0% 0%, 0% 100%, 100% 100%, 100% 0%, 0% 0%,
8% 9.85%, 92% 9.85%, 92% 47.15%, 8% 47.15%, 8% 9.85%
) !important;
}
}
// -----------------------------------------------------------------------------
// 8. MODAL ANIMATION
// -----------------------------------------------------------------------------
// Note: --card-opacity is intentionally NOT registered via @property.
// Registering it as <number> makes it interpolatable, causing the browser
// to smoothly transition it when JS sets it via inline style — creating the
// unwanted slow fade-in. Without registration it changes instantly.
@property --pointer-x { syntax: '<percentage>'; inherits: true; initial-value: 50%; }
@property --pointer-y { syntax: '<percentage>'; inherits: true; initial-value: 50%; }
@property --background-x { syntax: '<percentage>'; inherits: true; initial-value: 50%; }
@property --background-y { syntax: '<percentage>'; inherits: true; initial-value: 50%; }
@property --pointer-from-center { syntax: '<number>'; inherits: true; initial-value: 0; }
@property --pointer-from-left { syntax: '<number>'; inherits: true; initial-value: 0.5; }
@property --pointer-from-top { syntax: '<number>'; inherits: true; initial-value: 0.5; }
@keyframes holo-modal-opacity {
0% { opacity: 0; }
4% { opacity: 0; }
8% { opacity: 0.35; }
85% { opacity: 0.35; }
90%, 100%{ opacity: 0; }
}
@keyframes holo-modal-position {
0% {
--pointer-x: 50%; --pointer-y: 50%; --pointer-x: 50%; --pointer-y: 50%;
--background-x: 50%; --background-y: 50%; --background-x: 50%; --background-y: 50%;
--pointer-from-center: 0; --pointer-from-left: 0.5; --pointer-from-top: 0.5; --pointer-from-center: 0; --pointer-from-left: 0.5; --pointer-from-top: 0.5;
} }
4% { --card-opacity: 0; } 8% {
8% {
--card-opacity: 0.35;
--pointer-x: 25%; --pointer-y: 15%; --pointer-x: 25%; --pointer-y: 15%;
--background-x: 38%; --background-y: 28%; --background-x: 38%; --background-y: 28%;
--pointer-from-center: 0.85; --pointer-from-left: 0.25; --pointer-from-top: 0.15; --pointer-from-center: 0.85; --pointer-from-left: 0.25; --pointer-from-top: 0.15;
} }
25% { 25% {
--pointer-x: 70%; --pointer-y: 30%; --pointer-x: 70%; --pointer-y: 30%;
--background-x: 64%; --background-y: 34%; --background-x: 64%; --background-y: 34%;
--pointer-from-center: 0.9; --pointer-from-left: 0.70; --pointer-from-top: 0.30; --pointer-from-center: 0.9; --pointer-from-left: 0.70; --pointer-from-top: 0.30;
} }
45% { 45% {
--pointer-x: 80%; --pointer-y: 70%; --pointer-x: 80%; --pointer-y: 70%;
--background-x: 74%; --background-y: 68%; --background-x: 74%; --background-y: 68%;
--pointer-from-center: 0.88; --pointer-from-left: 0.80; --pointer-from-top: 0.70; --pointer-from-center: 0.88; --pointer-from-left: 0.80; --pointer-from-top: 0.70;
} }
65% { 65% {
--pointer-x: 35%; --pointer-y: 80%; --pointer-x: 35%; --pointer-y: 80%;
--background-x: 38%; --background-y: 76%; --background-x: 38%; --background-y: 76%;
--pointer-from-center: 0.8; --pointer-from-left: 0.35; --pointer-from-top: 0.80; --pointer-from-center: 0.8; --pointer-from-left: 0.35; --pointer-from-top: 0.80;
} }
85% { 85% {
--card-opacity: 0.35;
--pointer-x: 25%; --pointer-y: 15%; --pointer-x: 25%; --pointer-y: 15%;
--background-x: 38%; --background-y: 28%; --background-x: 38%; --background-y: 28%;
--pointer-from-center: 0.85; --pointer-from-center: 0.85;
} }
90% { --card-opacity: 0; }
100% { 100% {
--card-opacity: 0;
--pointer-x: 50%; --pointer-y: 50%; --pointer-x: 50%; --pointer-y: 50%;
--background-x: 50%; --background-y: 50%; --background-x: 50%; --background-y: 50%;
--pointer-from-center: 0; --pointer-from-center: 0;
@@ -190,160 +524,162 @@
} }
.card-image-wrap.holo-modal-mode { .card-image-wrap.holo-modal-mode {
--card-opacity: 0; // Animate pointer vars on the wrapper so CSS custom props interpolate
animation: holo-modal-position 60s ease-in-out infinite;
animation-delay: var(--shimmer-delay, -2s);
.holo-shine, .holo-shine,
.holo-glare { .holo-glare {
animation: holo-modal-pulse 60s ease-in-out infinite; // Animate opacity directly — no @property needed, native interpolation
animation: holo-modal-opacity 60s ease-in-out infinite;
animation-delay: var(--shimmer-delay, -2s); animation-delay: var(--shimmer-delay, -2s);
} }
&[data-holo-active] { &[data-holo-active] {
--card-opacity: 0.45; animation-play-state: paused;
.holo-shine, .holo-shine { opacity: 0.20; }
.holo-glare { animation-play-state: paused; } .holo-glare { opacity: calc(0.20 * 0.4); }
} }
} }
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// 4. RARITY -> CLIP-PATH BRIDGE // 9. MOBILE / TOUCH — static holofoil overlay, no JS tracking
//
// @media (hover: none) targets touchscreens only.
// Technique from joshdance.com/100/Day50: two rainbow gradients at opposing
// fixed positions interact via blend modes to create a static holographic sheen.
// Where the two gradient bands cross, the additive blending creates bright
// rainbow intersections that read as a light-catch effect — no pointer needed.
//
// Implementation:
// - .holo-shine gets the two-gradient stack at fixed diagonal positions
// - ::before moves in the opposite direction (negative position) so the
// crossing point creates the characteristic holofoil bright intersection
// - opacity is always-on at a low value — no hover event needed
// - will-change reset to auto — no GPU layer reservation needed
// - Glitter hidden — parallax position is meaningless without tracking
// - No CSS animation on touch — pure static CSS, zero JS involvement
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
.image-grow, @media (hover: none) {
.card-image-wrap {
.holo-shine,
.holo-glare {
will-change: auto;
}
// Disable any animation on modal cards on touch — static treatment handles it
.card-image-wrap.holo-modal-mode {
animation: none;
// No effect on common/uncommon or unrecognised wrapper
&[data-rarity="common"],
&[data-rarity="uncommon"],
&:not([data-rarity]) {
.holo-shine, .holo-shine,
.holo-glare { display: none; } .holo-glare { animation: none; }
} }
// Standard holo — artwork area only // Suppress glitter — parallax position is meaningless without pointer tracking
&[data-rarity="rare holo"] { .holo-glare::after { display: none; }
.holo-shine { clip-path: var(--clip); }
&[data-subtypes^="stage"] .holo-shine { clip-path: var(--clip-stage); }
&[data-subtypes^="supporter"] .holo-shine,
&[data-subtypes^="item"] .holo-shine { clip-path: var(--clip-trainer); }
}
// Cosmos holo // ── Static holofoil overlay for all effect zones on touch ─────────────────
&[data-rarity="rare holo cosmos"] { // Override the JS-driven background-position values with fixed diagonals.
.holo-shine { clip-path: var(--clip); } // The ::before pseudo moves in the opposite direction to create crossing bands.
&[data-subtypes^="stage"] .holo-shine { clip-path: var(--clip-stage); }
&[data-subtypes^="supporter"] .holo-shine { clip-path: var(--clip-trainer); }
}
&[data-rarity="radiant rare"] { .holo-shine { clip-path: var(--clip-borders); } } .image-grow,
&[data-rarity="amazing rare"] { .holo-shine { clip-path: var(--clip); } } .card-image-wrap {
&[data-rarity="trainer gallery rare holo"], // Zone 1 — art window
&[data-rarity="rare holo"][data-trainer-gallery="true"] { &[data-rarity="Rare" i]:not([data-variant="Reverse Holofoil" i]):not([data-variant="Normal" i]),
.holo-shine { clip-path: var(--clip-borders); } &[data-rarity="Amazing Rare" i]:not([data-variant="Reverse Holofoil" i]),
} &[data-rarity="Classic Collection" i]:not([data-variant="Reverse Holofoil" i]),
&[data-rarity="Holo Rare" i]:not([data-variant="Reverse Holofoil" i]),
&[data-variant="Holofoil" i]:not([data-variant="Reverse Holofoil" i]),
&[data-variant="1st Edition Holofoil" i],
// Zone 2 full card
&[data-rarity="Ultra Rare" i]:not([data-variant="Reverse Holofoil" i]),
&[data-rarity="Character Rare" i]:not([data-variant="Reverse Holofoil" i]),
&[data-rarity="Illustration Rare" i]:not([data-variant="Reverse Holofoil" i]),
&[data-rarity="Special Illustration Rare" i]:not([data-variant="Reverse Holofoil" i]),
&[data-rarity="Double Rare" i]:not([data-variant="Reverse Holofoil" i]),
&[data-rarity="Hyper Rare" i]:not([data-variant="Reverse Holofoil" i]),
&[data-rarity="Mega Rare" i]:not([data-variant="Reverse Holofoil" i]),
&[data-rarity="Mega Attack Rare" i]:not([data-variant="Reverse Holofoil" i]),
&[data-rarity="ACE Spec Rare" i]:not([data-variant="Reverse Holofoil" i]),
&[data-rarity="ACE Rare" i]:not([data-variant="Reverse Holofoil" i]),
&[data-rarity="Art Rare" i]:not([data-variant="Reverse Holofoil" i]),
&[data-rarity="Special Art Rare" i]:not([data-variant="Reverse Holofoil" i]),
&[data-rarity="Black White Rare" i]:not([data-variant="Reverse Holofoil" i]),
&[data-rarity="Character Super Rare" i]:not([data-variant="Reverse Holofoil" i]),
&[data-rarity="Mega Ultra Rare" i]:not([data-variant="Reverse Holofoil" i]),
&[data-rarity="Rare BREAK" i]:not([data-variant="Reverse Holofoil" i]),
&[data-rarity="Secret Rare" i]:not([data-variant="Reverse Holofoil" i]),
&[data-rarity="Shiny Holo Rare" i]:not([data-variant="Reverse Holofoil" i]),
&[data-rarity="Shiny Rare" i]:not([data-variant="Reverse Holofoil" i]),
&[data-rarity="Shiny Secret Rare" i]:not([data-variant="Reverse Holofoil" i]),
&[data-rarity="Shiny Ultra Rare" i]:not([data-variant="Reverse Holofoil" i]),
// Zone 3 inverse (Reverse Holofoil + Prism Rare)
// Energy colour woven in via --card-glow, same as desktop Zone 3
&[data-variant="Reverse Holofoil" i],
&[data-rarity="Prism Rare" i] {
&[data-rarity="rare shiny"] { // Energy colour multiply tint — kept subtle on mobile
.holo-shine { clip-path: var(--clip); } &::after {
&[data-subtypes^="stage"] .holo-shine { clip-path: var(--clip-stage); } opacity: 0.04;
} }
// Reverse holo by rarity — borders only .holo-shine {
&[data-rarity$="reverse holo"] { .holo-shine { clip-path: var(--clip-invert); } } background-image:
// Reverse Holofoil variant — borders only var(--grain),
&[data-variant="Reverse Holofoil"] { .holo-shine { clip-path: var(--clip-invert); } } repeating-linear-gradient(110deg,
var(--card-glow),
var(--blue),
var(--card-glow),
var(--green),
var(--yellow),
var(--card-glow),
var(--red),
var(--violet),
var(--card-glow)
);
background-size: 33%, 400% 400%;
background-repeat: repeat, no-repeat;
background-blend-mode: soft-light, normal;
background-position: center, 38% 25%;
filter: brightness(1.0) contrast(1.1) saturate(1.0);
opacity: 0.35;
// True holofoil variants + full-bleed rarities — no clip &::before {
&[data-variant="Holofoil"], background-image:
&[data-variant="1st Edition Holofoil"], repeating-linear-gradient(110deg,
&[data-variant="Unlimited Holofoil"], var(--card-glow),
&[data-rarity="rare ultra"], var(--blue),
&[data-rarity="rare holo v"], var(--card-glow),
&[data-rarity="rare holo vmax"], var(--green),
&[data-rarity="rare holo vstar"], var(--yellow),
&[data-rarity="rare shiny v"], var(--card-glow),
&[data-rarity="rare shiny vmax"], var(--red),
&[data-rarity="rare rainbow"], var(--violet),
&[data-rarity="rare rainbow alt"], var(--card-glow)
&[data-rarity="rare secret"] { );
.holo-shine { clip-path: none; } background-size: 400% 400%;
} background-position: 62% 75%;
mix-blend-mode: color-dodge;
opacity: 0.18;
filter: brightness(1.0) contrast(1.1) saturate(1.0);
}
// Foil variant shine/glare — clip handled above per variant type &::after { display: none; }
&[data-variant="Holofoil"], }
&[data-variant="Reverse Holofoil"],
&[data-variant="1st Edition Holofoil"], .holo-glare {
&[data-variant="Unlimited Holofoil"] { opacity: 0.15;
.holo-shine { background-image: radial-gradient(
background-image: farthest-corner circle at 35% 25%,
radial-gradient( var(--card-glow) 0%,
circle at var(--pointer-x) var(--pointer-y), hsla(0, 0%, 100%, 0.2) 30%,
#fff 5%, #000 50%, #fff 80% hsla(0, 0%, 0%, 0.3) 90%
),
linear-gradient(
var(--foil-angle, -45deg),
#000 15%, #fff, #000 85%
); );
background-blend-mode: soft-light, difference; filter: brightness(0.8) contrast(1.5);
background-size: 120% 120%, 200% 200%; }
background-position:
center center,
calc(100% * var(--pointer-from-left)) calc(100% * var(--pointer-from-top));
filter: brightness(var(--foil-brightness, 0.4)) contrast(1.3) saturate(var(--foil-saturation, 0.5));
mix-blend-mode: color-dodge;
opacity: calc((var(--card-opacity) * 0.9) - (var(--pointer-from-center) * 0.1));
}
.holo-glare {
opacity: calc(var(--card-opacity) * 0.5);
background-image: radial-gradient(
farthest-corner circle at var(--pointer-x) var(--pointer-y),
hsla(0, 0%, 100%, 0.5) 10%,
hsla(0, 0%, 100%, 0.25) 30%,
hsla(0, 0%, 0%, 0.4) 90%
);
filter: brightness(0.7) contrast(1.2);
mix-blend-mode: overlay;
}
}
}
// -----------------------------------------------------------------------------
// 5. DEFAULT HOLO SHINE / GLARE
// Fallback for rarities not explicitly handled above.
// -----------------------------------------------------------------------------
.image-grow,
.card-image-wrap {
&[data-rarity]:not([data-rarity="common"]):not([data-rarity="uncommon"]) {
.holo-shine {
background-image:
repeating-linear-gradient(110deg,
var(--violet), var(--blue), var(--green), var(--yellow), var(--red),
var(--violet), var(--blue), var(--green), var(--yellow), var(--red)
);
background-position:
calc(((50% - var(--background-x)) * 2.6) + 50%)
calc(((50% - var(--background-y)) * 3.5) + 50%);
background-size: 400% 400%;
filter: brightness(0.7) contrast(0.9) saturate(0.8);
mix-blend-mode: color-dodge;
opacity: calc(var(--card-opacity) * 0.6);
}
.holo-glare {
background-image: radial-gradient(
farthest-corner circle at var(--pointer-x) var(--pointer-y),
hsla(0, 0%, 100%, 0.35) 10%,
hsla(0, 0%, 100%, 0.15) 30%,
hsla(0, 0%, 0%, 0.35) 90%
);
opacity: calc(var(--card-opacity) * 0.4);
mix-blend-mode: overlay;
filter: brightness(0.7) contrast(1.1);
} }
} }
} }

View File

@@ -24,7 +24,7 @@ $container-max-widths: (
@import "_bootstrap"; @import "_bootstrap";
// ── Holofoil ────────────────────────────────────────────────────────────── // ── Holofoil ──────────────────────────────────────────────────────────────
//@import "_holofoil-integration"; // also pulls in _card.scss @import "_holofoil-integration"; // also pulls in _card.scss
/* -------------------------------------------------- /* --------------------------------------------------
Root Variables Root Variables
@@ -670,4 +670,4 @@ input[type="search"]::-webkit-search-cancel-button {
background-size: 1rem; background-size: 1rem;
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeCAYAAAA7MK6iAAAAn0lEQVR42u3UMQrDMBBEUZ9WfQqDmm22EaTyjRMHAlM5K+Y7lb0wnUZPIKHlnutOa+25Z4D++MRBX98MD1V/trSppLKHqj9TTBWKcoUqffbUcbBBEhTjBOV4ja4l4OIAZThEOV6jHO8ARXD+gPPvKMABinGOrnu6gTNUawrcQKNCAQ7QeTxORzle3+sDfjJpPCqhJh7GixZq4rHcc9l5A9qZ+WeBhgEuAAAAAElFTkSuQmCC); background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeCAYAAAA7MK6iAAAAn0lEQVR42u3UMQrDMBBEUZ9WfQqDmm22EaTyjRMHAlM5K+Y7lb0wnUZPIKHlnutOa+25Z4D++MRBX98MD1V/trSppLKHqj9TTBWKcoUqffbUcbBBEhTjBOV4ja4l4OIAZThEOV6jHO8ARXD+gPPvKMABinGOrnu6gTNUawrcQKNCAQ7QeTxORzle3+sDfjJpPCqhJh7GixZq4rHcc9l5A9qZ+WeBhgEuAAAAAElFTkSuQmCC);
} }
-------------------------------------------------- */ -------------------------------------------------- */

View File

@@ -1,53 +1,57 @@
/** /**
* holofoil-init.js * holofoil-init.js
* -----------------------------------------------------------------------------
* Instruments .image-grow and .card-image-wrap with the holofoil effect system. * Instruments .image-grow and .card-image-wrap with the holofoil effect system.
*
* GRID (.image-grow)
* Effect is invisible at rest. On hover, pointer tracking drives the shine
* and glare layers. The card lift/scale comes from main.scss as before.
*
* MODAL (.card-image-wrap)
* Effect sweeps autonomously once per minute via CSS animation.
* Pointer tracking takes over while the user hovers the image.
*
* DEFAULT FALLBACK
* If data-default="true" is set (onerror in the Astro markup), no effect
* is applied -- even if the attribute appears after stamp() has run.
* -----------------------------------------------------------------------------
*/ */
(function HolofoilSystem() { (function HolofoilSystem() {
'use strict'; 'use strict';
// -- Constants -------------------------------------------------------------- // Variants that receive NO effect
const NO_EFFECT_VARIANTS = new Set(['normal']);
const SHIMMER_SEL = [ // Variants that always receive an effect regardless of rarity
'.image-grow[data-rarity]', const HOLO_VARIANTS = new Set([
'.image-grow[data-variant="Holofoil"]', 'reverse holofoil',
'.image-grow[data-variant="1st Edition Holofoil"]', 'holofoil',
'.image-grow[data-variant="Unlimited Holofoil"]', '1st edition holofoil',
'.image-grow[data-variant="Reverse Holofoil"]', ]);
'.card-image-wrap[data-rarity]',
'.card-image-wrap[data-variant="Holofoil"]', // Rarities that receive an effect
'.card-image-wrap[data-variant="1st Edition Holofoil"]', const HOLO_RARITIES = new Set([
'.card-image-wrap[data-variant="Unlimited Holofoil"]', // Art window zone
'.card-image-wrap[data-variant="Reverse Holofoil"]', 'rare',
].join(','); 'amazing rare',
'classic collection',
'holo rare',
// Full card zone
'ultra rare',
'character rare',
'illustration rare',
'special illustration rare',
'double rare',
'hyper rare',
'mega rare',
'mega attack rare',
'ace spec rare',
'ace rare',
'art rare',
'special art rare',
'black white rare',
'character super rare',
'mega ultra rare',
'rare break',
'secret rare',
'shiny holo rare',
'shiny rare',
'shiny secret rare',
'shiny ultra rare',
// Inverse zone
'prism rare',
]);
const ALL_WRAPPERS_SEL = '.image-grow, .card-image-wrap'; const ALL_WRAPPERS_SEL = '.image-grow, .card-image-wrap';
// Foil variant visual randomisation
const FOIL_ANGLE_MIN = -65, FOIL_ANGLE_MAX = -25;
const FOIL_BRITE_MIN = 0.18, FOIL_BRITE_MAX = 0.32;
const FOIL_SAT_MIN = 0.40, FOIL_SAT_MAX = 0.75;
const SKIP_RARITIES = new Set(['common', 'uncommon', '']);
// -- Helpers ----------------------------------------------------------------
const rand = (min, max) => parseFloat((Math.random() * (max - min) + min).toFixed(2)); const rand = (min, max) => parseFloat((Math.random() * (max - min) + min).toFixed(2));
const clamp01 = n => Math.max(0, Math.min(1, n)); const clamp01 = n => Math.max(0, Math.min(1, n));
@@ -56,13 +60,13 @@
const fromTop = clamp01((y - rect.top) / rect.height); const fromTop = clamp01((y - rect.top) / rect.height);
const fromCenter = clamp01(Math.sqrt((fromLeft - 0.5) ** 2 + (fromTop - 0.5) ** 2) * 2); const fromCenter = clamp01(Math.sqrt((fromLeft - 0.5) ** 2 + (fromTop - 0.5) ** 2) * 2);
return { return {
px: fromLeft * 100, px: fromLeft * 100,
py: fromTop * 100, py: fromTop * 100,
fromLeft, fromLeft,
fromTop, fromTop,
fromCenter, fromCenter,
bgX: 50 + (fromLeft - 0.5) * 30, bgX: 50 + (fromLeft - 0.5) * -30,
bgY: 50 + (fromTop - 0.5) * 30, bgY: 50 + (fromTop - 0.5) * -30,
}; };
} }
@@ -76,12 +80,18 @@
el.style.setProperty('--background-y', v.bgY.toFixed(1) + '%'); el.style.setProperty('--background-y', v.bgY.toFixed(1) + '%');
} }
const isHoloVariant = v => ['Holofoil', 'Reverse Holofoil', '1st Edition Holofoil', 'Unlimited Holofoil'].includes(v); function shouldHaveEffect(el) {
const isModalWrapper = el => el.classList.contains('card-image-wrap'); if (el.dataset.default === 'true') return false;
const isDefault = el => el.dataset.default === 'true'; // Also check if the card image itself is the default fallback
const img = el.querySelector('img');
if (img && img.src && img.src.endsWith('/cards/default.jpg')) return false;
// -- Child injection -------------------------------------------------------- const variant = (el.dataset.variant || '').toLowerCase().trim();
const rarity = (el.dataset.rarity || '').toLowerCase().trim();
if (NO_EFFECT_VARIANTS.has(variant)) return false;
if (HOLO_VARIANTS.has(variant)) return true;
if (HOLO_RARITIES.has(rarity)) return true;
return false;
}
function injectChildren(el) { function injectChildren(el) {
if (el.querySelector('.holo-shine')) return; if (el.querySelector('.holo-shine')) return;
@@ -93,18 +103,10 @@
el.appendChild(glare); el.appendChild(glare);
} }
// -- Default image guard ----------------------------------------------------
/**
* Watch for the onerror handler in the Astro markup setting data-default="true"
* after stamp() has already run. Hide the effect children immediately when seen.
*/
function watchForDefault(el) { function watchForDefault(el) {
if (isDefault(el)) return; if (el.dataset.default === 'true') return;
var observer = new MutationObserver(function() { var observer = new MutationObserver(function() {
if (isDefault(el)) { if (el.dataset.default === 'true') {
var shine = el.querySelector('.holo-shine'); var shine = el.querySelector('.holo-shine');
var glare = el.querySelector('.holo-glare'); var glare = el.querySelector('.holo-glare');
if (shine) shine.style.display = 'none'; if (shine) shine.style.display = 'none';
@@ -112,81 +114,54 @@
observer.disconnect(); observer.disconnect();
} }
}); });
observer.observe(el, { attributes: true, attributeFilter: ['data-default'] }); observer.observe(el, { attributes: true, attributeFilter: ['data-default'] });
} }
const canHover = window.matchMedia('(hover: hover)').matches;
// -- Stamp ------------------------------------------------------------------
function stamp(el) { function stamp(el) {
if (el.dataset.holoInit) return; if (el.dataset.holoInit) return;
if (!shouldHaveEffect(el)) {
// Skip if already a default fallback image
if (isDefault(el)) {
el.dataset.holoInit = 'skip'; el.dataset.holoInit = 'skip';
return; return;
} }
const rarity = (el.dataset.rarity || '').toLowerCase();
const variant = el.dataset.variant || '';
const hasHoloRarity = rarity && !SKIP_RARITIES.has(rarity);
const hasHoloVariant = isHoloVariant(variant);
if (!hasHoloRarity && !hasHoloVariant) {
el.dataset.holoInit = 'skip';
return;
}
injectChildren(el); injectChildren(el);
if (el.classList.contains('card-image-wrap')) {
// Per-card foil visual randomisation (angle/brightness/saturation) if (canHover) {
if (hasHoloVariant) { // Desktop: use hover + pointer tracking, same as grid cards.
el.style.setProperty('--foil-angle', Math.round(rand(FOIL_ANGLE_MIN, FOIL_ANGLE_MAX)) + 'deg'); // No animation — CSS :hover rule controls --card-opacity directly.
el.style.setProperty('--foil-brightness', rand(FOIL_BRITE_MIN, FOIL_BRITE_MAX).toFixed(2)); el.classList.remove('holo-modal-mode');
el.style.setProperty('--foil-saturation', rand(FOIL_SAT_MIN, FOIL_SAT_MAX ).toFixed(2)); } else {
// Touch: use the autonomous CSS animation sweep.
el.classList.add('holo-modal-mode');
el.style.setProperty('--shimmer-delay', rand(-8, 0) + 's');
}
} }
// Modal-only: set a stable delay offset for the autonomous CSS animation
if (isModalWrapper(el)) {
el.classList.add('holo-modal-mode');
el.style.setProperty('--shimmer-delay', rand(-8, 0) + 's');
}
watchForDefault(el); watchForDefault(el);
el.dataset.holoInit = '1'; el.dataset.holoInit = '1';
} }
function stampAll(root) {
(root || document).querySelectorAll(ALL_WRAPPERS_SEL).forEach(stamp);
}
// -- Pointer tracking -------------------------------------------------------
const pointerState = new WeakMap(); const pointerState = new WeakMap();
function onPointerEnter(e) { function onPointerEnter(e) {
const el = e.currentTarget; const el = e.currentTarget;
if (el.dataset.holoInit !== '1' || isDefault(el)) return; if (el.dataset.holoInit !== '1') return;
el.dataset.holoActive = '1'; el.dataset.holoActive = '1';
// Inline style wins over CSS immediately — @property not registered for
// --card-opacity so no interpolation. All calc() multipliers in child
// rules (glare * 0.4, glitter * 0.6) work correctly from this single var.
el.style.setProperty('--card-opacity', '0.2');
if (!pointerState.has(el)) pointerState.set(el, { rafId: null }); if (!pointerState.has(el)) pointerState.set(el, { rafId: null });
} }
function onPointerMove(e) { function onPointerMove(e) {
const el = e.currentTarget; const el = e.currentTarget;
if (el.dataset.holoInit !== '1') return; if (el.dataset.holoInit !== '1') return;
const state = pointerState.get(el); const state = pointerState.get(el);
if (!state) return; if (!state) return;
if (state.rafId) cancelAnimationFrame(state.rafId); if (state.rafId) cancelAnimationFrame(state.rafId);
state.rafId = requestAnimationFrame(function() { state.rafId = requestAnimationFrame(function() {
const rect = el.getBoundingClientRect(); applyPointerVars(el, pointerVars(e.clientX, e.clientY, el.getBoundingClientRect()));
applyPointerVars(el, pointerVars(e.clientX, e.clientY, rect));
state.rafId = null; state.rafId = null;
}); });
} }
@@ -194,46 +169,39 @@
function onPointerLeave(e) { function onPointerLeave(e) {
const el = e.currentTarget; const el = e.currentTarget;
if (el.dataset.holoInit !== '1') return; if (el.dataset.holoInit !== '1') return;
const state = pointerState.get(el); const state = pointerState.get(el);
if (state && state.rafId) { cancelAnimationFrame(state.rafId); state.rafId = null; } if (state && state.rafId) { cancelAnimationFrame(state.rafId); state.rafId = null; }
delete el.dataset.holoActive; delete el.dataset.holoActive;
// Remove inline style so CSS default (--card-opacity: 0) takes over instantly
if (isModalWrapper(el)) { el.style.removeProperty('--card-opacity');
// Let the CSS animation resume driving --card-opacity
el.style.removeProperty('--card-opacity');
}
} }
function attachListeners(el) { function attachListeners(el) {
if (el.dataset.holoListeners) return; if (el.dataset.holoListeners) return;
// On touch-only devices the CSS static shimmer handles the effect.
// Skip JS pointer tracking — pointermove never fires on touchscreens
// and registering listeners wastes memory with no benefit.
if (!window.matchMedia('(hover: hover)').matches) return;
el.addEventListener('pointerenter', onPointerEnter, { passive: true }); el.addEventListener('pointerenter', onPointerEnter, { passive: true });
el.addEventListener('pointermove', onPointerMove, { passive: true }); el.addEventListener('pointermove', onPointerMove, { passive: true });
el.addEventListener('pointerleave', onPointerLeave, { passive: true }); el.addEventListener('pointerleave', onPointerLeave, { passive: true });
el.dataset.holoListeners = '1'; el.dataset.holoListeners = '1';
} }
function attachAllListeners(root) { function stampAll(root) {
(root || document).querySelectorAll(SHIMMER_SEL).forEach(function(el) { (root || document).querySelectorAll(ALL_WRAPPERS_SEL).forEach(function(el) {
stamp(el); stamp(el);
if (el.dataset.holoInit === '1') attachListeners(el); if (el.dataset.holoInit === '1') attachListeners(el);
}); });
} }
// -- MutationObserver: react to HTMX / infinite scroll ----------------------
function observeGrid() { function observeGrid() {
var grid = document.getElementById('cardGrid'); var grid = document.getElementById('cardGrid');
if (!grid) return; if (!grid) return;
new MutationObserver(function(mutations) { new MutationObserver(function(mutations) {
for (var i = 0; i < mutations.length; i++) { mutations.forEach(function(m) {
var nodes = mutations[i].addedNodes; m.addedNodes.forEach(function(node) {
for (var j = 0; j < nodes.length; j++) { if (node.nodeType !== 1) return;
var node = nodes[j];
if (node.nodeType !== 1) continue;
if (node.matches && node.matches(ALL_WRAPPERS_SEL)) { if (node.matches && node.matches(ALL_WRAPPERS_SEL)) {
stamp(node); stamp(node);
if (node.dataset.holoInit === '1') attachListeners(node); if (node.dataset.holoInit === '1') attachListeners(node);
@@ -244,8 +212,8 @@
if (el.dataset.holoInit === '1') attachListeners(el); if (el.dataset.holoInit === '1') attachListeners(el);
}); });
} }
} });
} });
}).observe(grid, { childList: true, subtree: true }); }).observe(grid, { childList: true, subtree: true });
} }
@@ -253,20 +221,32 @@
var modal = document.getElementById('cardModal'); var modal = document.getElementById('cardModal');
if (!modal) return; if (!modal) return;
new MutationObserver(function() { new MutationObserver(function(mutations) {
modal.querySelectorAll(ALL_WRAPPERS_SEL).forEach(function(el) { mutations.forEach(function(m) {
stamp(el); m.addedNodes.forEach(function(node) {
if (el.dataset.holoInit === '1') attachListeners(el); if (node.nodeType !== 1) return;
var wrappers = [];
if (node.matches && node.matches(ALL_WRAPPERS_SEL)) wrappers.push(node);
if (node.querySelectorAll) {
node.querySelectorAll(ALL_WRAPPERS_SEL).forEach(function(el) {
wrappers.push(el);
});
}
wrappers.forEach(function(el) {
// Reset stamp so each new card is evaluated fresh
delete el.dataset.holoInit;
stamp(el);
if (el.dataset.holoInit === '1') attachListeners(el);
});
});
}); });
}).observe(modal, { childList: true, subtree: true }); }).observe(modal, { childList: true, subtree: true });
} }
// -- Bootstrap --------------------------------------------------------------
function init() { function init() {
stampAll(); stampAll();
attachAllListeners();
observeGrid(); observeGrid();
observeModal(); observeModal();
} }

View File

@@ -44,7 +44,7 @@ import BackToTop from "./BackToTop.astro"
<BackToTop /> <BackToTop />
<!-- <script src="src/assets/js/holofoil-init.js" is:inline></script> --> <script src="src/assets/js/holofoil-init.js" is:inline></script>
<script is:inline> <script is:inline>
(function () { (function () {
@@ -112,9 +112,6 @@ import BackToTop from "./BackToTop.astro"
// ── Global helpers ──────────────────────────────────────────────────────── // ── Global helpers ────────────────────────────────────────────────────────
window.copyImage = async function(img) { window.copyImage = async function(img) {
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
try { try {
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
@@ -130,17 +127,10 @@ import BackToTop from "./BackToTop.astro"
clean.src = img.src; clean.src = img.src;
}); });
const blob = await new Promise((resolve, reject) => {
canvas.toBlob(b => b ? resolve(b) : reject(new Error('toBlob failed')), 'image/png');
});
if (isIOS) {
const file = new File([blob], 'card.png', { type: 'image/png' });
await navigator.share({ files: [file] });
return;
}
if (navigator.clipboard && navigator.clipboard.write) { 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 })]); await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
showCopyToast('📋 Image copied!', '#198754'); showCopyToast('📋 Image copied!', '#198754');
} else { } else {
@@ -159,7 +149,6 @@ import BackToTop from "./BackToTop.astro"
} }
} }
} catch (err) { } catch (err) {
if (err.name === 'AbortError') return;
console.error('Failed:', err); console.error('Failed:', err);
showCopyToast('❌ Copy failed', '#dc3545'); showCopyToast('❌ Copy failed', '#dc3545');
} }
@@ -399,11 +388,5 @@ import BackToTop from "./BackToTop.astro"
currentCardId = null; currentCardId = null;
updateNavButtons(null); updateNavButtons(null);
}); });
// ── AdSense re-init on infinite scroll ───────────────────────────────────
document.addEventListener('htmx:afterSwap', () => {
(window.adsbygoogle = window.adsbygoogle || []).push({});
});
})(); })();
</script> </script>

View File

@@ -1,7 +0,0 @@
<div class="d-none d-xl-block sticky-top mt-5" style="top: 70px;">
<ins class="adsbygoogle"
style="display:block"
data-ad-format="autorelaxed"
data-ad-client="ca-pub-1140571217687341"
data-ad-slot="8889263515"></ins>
</div>

View File

@@ -16,7 +16,6 @@ const { title } = Astro.props;
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="htmx-config" content='{"historyCacheSize": 50}'/> <meta name="htmx-config" content='{"historyCacheSize": 50}'/>
<meta name="google-adsense-account" content="ca-pub-1140571217687341">
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<title>{title}</title> <title>{title}</title>
@@ -43,4 +42,4 @@ const { title } = Astro.props;
<script src="../assets/js/main.js"></script> <script src="../assets/js/main.js"></script>
<script>import '../assets/js/priceChart.js';</script> <script>import '../assets/js/priceChart.js';</script>
</body> </body>
</html> </html>

View File

@@ -1,45 +1,17 @@
import { clerkMiddleware, createRouteMatcher, clerkClient } from '@clerk/astro/server'; // src/middleware.ts
import { clerkMiddleware, createRouteMatcher } from '@clerk/astro/server';
import type { AstroMiddlewareRequest, AstroMiddlewareResponse } from 'astro';
const isProtectedRoute = createRouteMatcher(['/pokemon']); const isProtectedRoute = createRouteMatcher([
const isAdminRoute = createRouteMatcher(['/admin']); '/pokemon',
]);
const TARGET_ORG_ID = "org_3Baav9czkRLLlC7g89oJWqRRulK"; export const onRequest = clerkMiddleware((auth, context) => {
const { isAuthenticated, redirectToSignIn } = auth()
export const onRequest = clerkMiddleware(async (auth, context) => {
const { isAuthenticated, userId, redirectToSignIn } = auth();
if (!isAuthenticated && isProtectedRoute(context.request)) { if (!isAuthenticated && isProtectedRoute(context.request)) {
return redirectToSignIn(); // Add custom logic to run before redirecting
return redirectToSignIn()
} }
});
if (isAdminRoute(context.request)) {
if (!isAuthenticated || !userId) {
return redirectToSignIn();
}
try {
const client = await clerkClient(context); // pass context here
const memberships = await client.organizations.getOrganizationMembershipList({
organizationId: TARGET_ORG_ID,
});
console.log("Total memberships found:", memberships.data.length);
console.log("Current userId:", userId);
console.log("Memberships:", JSON.stringify(memberships.data.map(m => ({
userId: m.publicUserData?.userId,
role: m.role,
})), null, 2));
const userMembership = memberships.data.find(
(m) => m.publicUserData?.userId === userId
);
if (!userMembership || userMembership.role !== "org:admin") {
return context.redirect("/");
}
} catch (e) {
console.error("Clerk membership check failed:", e);
return context.redirect("/");
}
}
});

View File

@@ -1,18 +0,0 @@
---
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';
---
<Layout title="Admin Panel">
<NavBar slot="navbar">
<NavItems slot="navItems" />
</NavBar>
<div class="row mb-4" slot="page">
<div class="col-12">
<h1>Admin Panel</h1>
</div>
</div>
<Footer slot="footer" />
</Layout>

View File

@@ -1,95 +0,0 @@
// src/pages/api/upload.ts
import type { APIRoute } from 'astro';
import { parse, stringify, transform } from 'csv';
import { Readable } from 'stream';
import { client } from '../../db/typesense';
import chalk from 'chalk';
import { db, ClosePool } from '../../db/index';
// Define the transformation logic
const transformer = transform({ parallel: 1 }, async function(this: any, row: any, callback: any) {
try {
// Specific query bsaed on tcgcollector CSV
const query = String(Object.values(row)[1]);
const setname = String(Object.values(row)[4]).replace(/Wizards of the coast promos/ig,'WoTC Promo');
const cardNumber = String(Object.values(row)[7]);
console.log(`${query} ${cardNumber} : ${setname}`);
// Use Typesense to find the card because we can easily use the combined fields
let cards = await client.collections('cards').documents().search({ q: query, query_by: 'productName', filter_by: `setName:\`${setname}\` && number:${cardNumber}` });
if (cards.hits?.length === 0) {
// Try without card number
cards = await client.collections('cards').documents().search({ q: query, query_by: 'productName', filter_by: `setName:\`${setname}\`` });
}
if (cards.hits?.length === 0) {
// Try without set name
cards = await client.collections('cards').documents().search({ q: query, query_by: 'productName', filter_by: `number:${cardNumber}` });
}
if (cards.hits?.length === 0) {
// I give up, just output the values from the csv
console.log(chalk.red(' - not found'));
const newRow = { ...row };
newRow.Variant = '';
newRow.marketPrice = '';
this.push(newRow);
}
else {
for (const card of cards.hits?.map((hit: any) => hit.document) ?? []) {
console.log(chalk.blue(` - ${card.cardId} : ${card.productName} : ${card.number}`), chalk.yellow(`${card.setName}`), chalk.green(`${card.variant}`));
const variant = await db.query.cards.findFirst({
with: { prices: true, tcgdata: true },
where: { cardId: card.cardId }
});
const newRow = { ...row };
newRow.Variant = variant?.variant;
newRow.marketPrice = variant?.prices.find(p => p.condition === 'Near Mint')?.marketPrice;
this.push(newRow);
}
}
callback();
} catch (error) {
callback(error);
}
});
export const POST: APIRoute = async ({ request }) => {
try {
const formData = await request.formData();
const file = formData.get('file') as File;
const inputStream = Readable.from(file.stream());
if (!file) {
return new Response('No file uploaded', { status: 400 });
}
// Pipe the streams: Read -> Parse -> Transform -> Stringify -> Write
const outputStream = inputStream
.on('error', (error) => console.error('Input stream error:', error))
.pipe(parse({ columns: true, trim: true }))
.on('error', (error) => console.error('Parse error:', error))
.pipe(transformer)
.on('error', (error) => console.error('Transform error:', error))
.pipe(stringify({ header: true }))
.on('error', (error) => console.error('Stringify error:', error));
// outputStream.on('finish', () => {
// ClosePool();
// }).on('error', (error) => {
// ClosePool();
// });
return new Response(outputStream as any, {
headers: {
'Content-Type': 'text/csv',
'Content-Disposition': 'attachment; filename=transformed.csv',
},
});
} catch (error) {
console.error('Error processing CSV stream:', error);
return new Response('Internal Server Error', { status: 500 });
}
};

View File

@@ -4,7 +4,7 @@ import Layout from '../layouts/Main.astro';
import NavItems from '../components/NavItems.astro'; import NavItems from '../components/NavItems.astro';
import NavBar from '../components/NavBar.astro'; import NavBar from '../components/NavBar.astro';
import Footer from '../components/Footer.astro'; import Footer from '../components/Footer.astro';
import { Show, SignInButton, SignUpButton, SignOutButton, GoogleOneTap, UserAvatar, UserButton, UserProfile } from '@clerk/astro/components' import { Show, SignInButton, SignUpButton, SignOutButton } from '@clerk/astro/components'
--- ---
<Layout title="Rigid's App Thing"> <Layout title="Rigid's App Thing">
<NavBar slot="navbar"> <NavBar slot="navbar">
@@ -16,41 +16,31 @@ import { Show, SignInButton, SignUpButton, SignOutButton, GoogleOneTap, UserAvat
<p class="text-secondary">(working title)</p> <p class="text-secondary">(working title)</p>
</div> </div>
<div class="col-12 col-md-6 mb-2"> <div class="col-12 col-md-6 mb-2">
<h2 class="mt-3">The Pokémon card tracker you actually want.</h2> <h2 class="mt-3">Welcome!</h2>
<p class="mt-2"> <p class="mt-2">
Browse real market prices and condition data across 70,000+ cards! No more 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.
juggling multiple tabs or guessing what your cards are worth.
</p> </p>
<p class="my-2"> <p class="my-2">
We're now open to everyone. Create a free account to get started — 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!
collection and inventory management tools are coming soon as part of a
premium plan.
</p> </p>
<Show when="signed-in"> <Show when="signed-in">
<a href="/pokemon" class="btn btn-warning mt-2">Take me to the cards!</a> <a href="/pokemon" class="btn btn-warning mt-2">Take me to the cards</a>
</Show> </Show>
</div> </div>
<div class="col-12 col-md-6 d-flex justify-content-end align-items-start mt-3"> <div class="col-12 col-md-6 d-flex justify-content-end align-items-start mt-3">
<div class="d-flex gap-3 mx-auto"> <div class="d-flex gap-3">
<Show when="signed-out"> <Show when="signed-out">
<div class="card border p-5 w-100">
<SignUpButton asChild mode="modal">
<button class="btn btn-success w-100 mb-2">Create free account</button>
</SignUpButton>
<SignInButton asChild mode="modal"> <SignInButton asChild mode="modal">
<p class="text-center text-secondary my-2">Already have an account?</p> <button class="btn btn-success">Sign In</button>
<button class="btn btn-outline-light w-100">Sign in</button>
</SignInButton> </SignInButton>
<p class="text-center h6 text-light mt-2 mb-0">Free to join!</p> <SignUpButton asChild mode="modal">
</div> <button class="btn btn-dark">Request Access</button>
<GoogleOneTap /> </SignUpButton>
</Show> </Show>
<Show when="signed-in"> <Show when="signed-in">
<div class="w-100">
<SignOutButton asChild> <SignOutButton asChild>
<button class="btn btn-danger mt-2 ms-auto float-end">Sign Out</button> <button class="btn btn-danger">Sign Out</button>
</SignOutButton> </SignOutButton>
</div>
</Show> </Show>
</div> </div>
</div> </div>

View File

@@ -1,26 +0,0 @@
---
import Layout from '../layouts/Main.astro';
import NavItems from '../components/NavItems.astro';
import NavBar from '../components/NavBar.astro';
import Footer from '../components/Footer.astro';
---
<Layout title="Rigid's App Thing">
<NavBar slot="navbar">
<NavItems slot="navItems" />
</NavBar>
<div class="row mb-4" slot="page">
<div class="col-12">
<h1>Rigid's App Thing</h1>
<p class="text-secondary">(working title)</p>
</div>
<div class="col-12">
<!-- src/components/FileUploader.astro -->
<form action="/api/upload" method="post" enctype="multipart/form-data">
<input type="file" name="file" accept=".csv" required />
<button type="submit">Upload CSV</button>
</form>
</div>
</div>
<Footer slot="footer" />
</Layout>

View File

@@ -46,41 +46,14 @@ const calculatedAt = (() => {
const dates = card.prices const dates = card.prices
.map(p => p.calculatedAt) .map(p => p.calculatedAt)
.filter(d => d) .filter(d => d)
.map(d => new Date(d!)); .map(d => new Date(d));
if (!dates.length) return null; if (!dates.length) return null;
return new Date(Math.max(...dates.map(d => d.getTime()))); return new Date(Math.max(...dates.map(d => d.getTime())));
})(); })();
// ── Spread-based volatility (high - low) / low ──────────────────────────── // ── Fetch price history + compute volatility ──────────────────────────────
// Log-return volatility was unreliable because marketPrice is a smoothed daily
// value, not transaction-driven. The 30-day high/low spread is a more honest
// proxy for price movement over the period.
const volatilityByCondition: Record<string, { label: string; spread: number }> = {};
for (const price of card?.prices ?? []) {
const condition = price.condition;
const low = Number(price.lowestPrice);
const high = Number(price.highestPrice);
const market = Number(price.marketPrice);
if (!low || !high || !market || market <= 0) {
volatilityByCondition[condition] = { label: '—', spread: 0 };
continue;
}
const spread = (high - low) / market;
const label = spread >= 0.50 ? 'High'
: spread >= 0.25 ? 'Medium'
: 'Low';
volatilityByCondition[condition] = { label, spread: Math.round(spread * 100) / 100 };
}
// ── Price history for chart ───────────────────────────────────────────────
const cardSkus = card?.prices?.length const cardSkus = card?.prices?.length
? await db.select().from(skus).where(eq(skus.productId, card.productId)) ? await db.select().from(skus).where(eq(skus.cardId, cardId))
: []; : [];
const skuIds = cardSkus.map(s => s.skuId); const skuIds = cardSkus.map(s => s.skuId);
@@ -99,6 +72,41 @@ const historyRows = skuIds.length
.orderBy(priceHistory.calculatedAt) .orderBy(priceHistory.calculatedAt)
: []; : [];
// Rolling 30-day cutoff for volatility calculation
const thirtyDaysAgo = new Date(Date.now() - 30 * 86_400_000);
const byCondition: Record<string, number[]> = {};
for (const row of historyRows) {
if (row.marketPrice == null) continue;
if (!row.calculatedAt) continue;
if (new Date(row.calculatedAt) < thirtyDaysAgo) continue;
const price = Number(row.marketPrice);
if (price <= 0) continue;
if (!byCondition[row.condition]) byCondition[row.condition] = [];
byCondition[row.condition].push(price);
}
function computeVolatility(prices: number[]): { label: string; monthlyVol: number } {
if (prices.length < 2) return { label: '—', monthlyVol: 0 };
const returns: number[] = [];
for (let i = 1; i < prices.length; i++) {
returns.push(Math.log(prices[i] / prices[i - 1]));
}
const mean = returns.reduce((a, b) => a + b, 0) / returns.length;
const variance = returns.reduce((sum, r) => sum + (r - mean) ** 2, 0) / (returns.length - 1);
const monthlyVol = Math.sqrt(variance) * Math.sqrt(30);
const label = monthlyVol >= 0.30 ? 'High'
: monthlyVol >= 0.15 ? 'Medium'
: 'Low';
return { label, monthlyVol: Math.round(monthlyVol * 100) / 100 };
}
const volatilityByCondition: Record<string, { label: string; monthlyVol: number }> = {};
for (const [condition, prices] of Object.entries(byCondition)) {
volatilityByCondition[condition] = computeVolatility(prices);
}
// ── Price history for chart (full history, not windowed) ──────────────────
const priceHistoryForChart = historyRows.map(row => ({ const priceHistoryForChart = historyRows.map(row => ({
condition: row.condition, condition: row.condition,
calculatedAt: row.calculatedAt calculatedAt: row.calculatedAt
@@ -107,11 +115,29 @@ const priceHistoryForChart = historyRows.map(row => ({
marketPrice: row.marketPrice, marketPrice: row.marketPrice,
})).filter(r => r.calculatedAt !== null); })).filter(r => r.calculatedAt !== null);
// ── Determine which range buttons to show ────────────────────────────────
const now = Date.now();
const oldestDate = historyRows.length
? Math.min(...historyRows
.filter(r => r.calculatedAt)
.map(r => new Date(r.calculatedAt!).getTime()))
: now;
const dataSpanDays = (now - oldestDate) / 86_400_000;
const showRanges = {
'1m': dataSpanDays >= 1,
'3m': dataSpanDays >= 60,
'6m': dataSpanDays >= 180,
'1y': dataSpanDays >= 365,
'all': dataSpanDays >= 400,
};
const conditionOrder = ["Near Mint", "Lightly Played", "Moderately Played", "Heavily Played", "Damaged"]; const conditionOrder = ["Near Mint", "Lightly Played", "Moderately Played", "Heavily Played", "Damaged"];
const conditionAttributes = (price: any) => { const conditionAttributes = (price: any) => {
const condition: string = price?.condition || "Near Mint"; const condition: string = price?.condition || "Near Mint";
const vol = volatilityByCondition[condition] ?? { label: '—', spread: 0 }; const vol = volatilityByCondition[condition] ?? { label: '—', monthlyVol: 0 };
const volatilityClass = (() => { const volatilityClass = (() => {
switch (vol.label) { switch (vol.label) {
@@ -124,7 +150,7 @@ const conditionAttributes = (price: any) => {
const volatilityDisplay = vol.label === '—' const volatilityDisplay = vol.label === '—'
? '—' ? '—'
: `${vol.label} (${(vol.spread * 100).toFixed(0)}%)`; : `${vol.label} (${(vol.monthlyVol * 100).toFixed(0)}%)`;
return { return {
"Near Mint": { label: "nav-nm", volatility: volatilityDisplay, volatilityClass, class: "show active" }, "Near Mint": { label: "nav-nm", volatility: volatilityDisplay, volatilityClass, class: "show active" },
@@ -164,6 +190,9 @@ const altSearchUrl = (card: any) => {
<!-- Card image column --> <!-- Card image column -->
<div class="col-sm-12 col-md-3"> <div class="col-sm-12 col-md-3">
<div class="position-relative mt-1"> <div class="position-relative mt-1">
<!-- card-image-wrap gives the modal image shimmer effects
without the hover lift/scale that image-grow has in main.scss -->
<div <div
class="card-image-wrap rounded-4" class="card-image-wrap rounded-4"
data-energy={card?.energyType} data-energy={card?.energyType}
@@ -172,13 +201,15 @@ const altSearchUrl = (card: any) => {
data-name={card?.productName} data-name={card?.productName}
> >
<img <img
src={`/static/cards/${card?.productId}.jpg`} src={`/cards/${card?.productId}.jpg`}
class="card-image w-100 img-fluid rounded-4" class="card-image w-100 img-fluid rounded-4"
alt={card?.productName} alt={card?.productName}
crossorigin="anonymous" crossorigin="anonymous"
onerror="this.onerror=null; this.src='/static/cards/default.jpg'; this.closest('.image-grow, .card-image-wrap')?.setAttribute('data-default','true')" onerror="this.onerror=null; this.src='/cards/default.jpg'; this.closest('.image-grow, .card-image-wrap')?.setAttribute('data-default','true')"
onclick="copyImage(this); dataLayer.push({'event': 'copiedImage'});" onclick="copyImage(this); dataLayer.push({'event': 'copiedImage'});"
/> />
<div class="holo-shine"></div>
<div class="holo-glare"></div>
</div> </div>
<span class="position-absolute top-50 start-0 d-inline"><FirstEditionIcon edition={card?.variant} /></span> <span class="position-absolute top-50 start-0 d-inline"><FirstEditionIcon edition={card?.variant} /></span>
@@ -241,11 +272,11 @@ const altSearchUrl = (card: any) => {
<p class="mb-0 mt-1">${price.marketPrice}</p> <p class="mb-0 mt-1">${price.marketPrice}</p>
</div> </div>
<div class="alert alert-dark rounded p-2 flex-fill d-flex flex-column mb-0"> <div class="alert alert-dark rounded p-2 flex-fill d-flex flex-column mb-0">
<h6 class="mb-auto">Low Price <span class="small p text-secondary">(30 day)</span></h6> <h6 class="mb-auto">Lowest Price</h6>
<p class="mb-0 mt-1">${price.lowestPrice}</p> <p class="mb-0 mt-1">${price.lowestPrice}</p>
</div> </div>
<div class="alert alert-dark rounded p-2 flex-fill d-flex flex-column mb-0"> <div class="alert alert-dark rounded p-2 flex-fill d-flex flex-column mb-0">
<h6 class="mb-auto">High Price <span class="small p text-secondary">(30 day)</span></h6> <h6 class="mb-auto">Highest Price</h6>
<p class="mb-0 mt-1">${price.highestPrice}</p> <p class="mb-0 mt-1">${price.highestPrice}</p>
</div> </div>
<div class={`alert rounded p-2 flex-fill d-flex flex-column mb-0 ${attributes?.volatilityClass}`}> <div class={`alert rounded p-2 flex-fill d-flex flex-column mb-0 ${attributes?.volatilityClass}`}>
@@ -260,14 +291,14 @@ const altSearchUrl = (card: any) => {
data-bs-trigger="hover focus click" data-bs-trigger="hover focus click"
data-bs-html="true" data-bs-html="true"
data-bs-title={` data-bs-title={`
<div class='tooltip-heading fw-bold mb-1'>30-Day Price Spread</div> <div class='tooltip-heading fw-bold mb-1'>Monthly Volatility</div>
<div class='small'> <div class='small'>
<p class="mb-1"> <p class="mb-1">
<strong>What this measures:</strong> how wide the gap between the 30-day low and high is, <strong>What this measures:</strong> how much the market price tends to move day-to-day,
relative to the market price. scaled up to a monthly expectation.
</p> </p>
<p class="mb-0"> <p class="mb-0">
A card with <strong>50%+ spread</strong> has seen significant price swings over the past month. A card with <strong>30% volatility</strong> typically swings ±30% over a month.
</p> </p>
</div> </div>
`} `}
@@ -281,7 +312,7 @@ const altSearchUrl = (card: any) => {
<!-- Table only — chart is outside the tab panes --> <!-- Table only — chart is outside the tab panes -->
<div class="w-100"> <div class="w-100">
<div class="alert alert-dark rounded p-2 mb-0 table-responsive d-none"> <div class="alert alert-dark rounded p-2 mb-0 table-responsive">
<h6>Latest Verified Sales</h6> <h6>Latest Verified Sales</h6>
<table class="table table-sm mb-0"> <table class="table table-sm mb-0">
<caption class="small">Filtered to remove mismatched language variants</caption> <caption class="small">Filtered to remove mismatched language variants</caption>
@@ -328,11 +359,11 @@ const altSearchUrl = (card: any) => {
</canvas> </canvas>
</div> </div>
<div class="btn-group btn-group-sm d-flex flex-row gap-1 justify-content-end mt-2" role="group" aria-label="Time range"> <div class="btn-group btn-group-sm d-flex flex-row gap-1 justify-content-end mt-2" role="group" aria-label="Time range">
<button type="button" class="btn btn-dark price-range-btn active" data-range="1m">1M</button> {showRanges['1m'] && <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> {showRanges['3m'] && <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> {showRanges['6m'] && <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> {showRanges['1y'] && <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> {showRanges['all'] && <button type="button" class="btn btn-dark price-range-btn" data-range="all">All</button>}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -48,53 +48,15 @@ const languageFilter = language === 'en' ? " && productLineName:=`Pokemon`"
// synonyms alone (e.g. terms that need to match across multiple set names) // synonyms alone (e.g. terms that need to match across multiple set names)
// and rewrites them into a direct filter, clearing the query so it doesn't // and rewrites them into a direct filter, clearing the query so it doesn't
// also try to text-match against card names. // also try to text-match against card names.
const EREADER_SETS = ['Expedition Base Set', 'Aquapolis', 'Skyridge', 'Battle-e'];
const ALIAS_FILTERS = [ const EREADER_RE = /^(e-?reader|e reader)$/i;
// ── Era / set groupings ───────────────────────────────────────────────
{ re: /^(e-?reader|e reader)$/i, field: 'setName',
values: ['Expedition Base Set', 'Aquapolis', 'Skyridge', 'Battle-e'] },
{ re: /^neo$/i, field: 'setName',
values: ['Neo Genesis', 'Neo Discovery', 'Neo Revelation', 'Neo Destiny'] },
{ re: /^(wotc|wizards)$/i, field: 'setName',
values: ['Base Set', 'Jungle', 'Fossil', 'Base Set 2', 'Team Rocket',
'Gym Heroes', 'Gym Challenge', 'Neo Genesis', 'Neo Discovery',
'Neo Revelation', 'Neo Destiny', 'Expedition Base Set',
'Aquapolis', 'Skyridge', 'Battle-e'] },
{ re: /^(sun\s*(&|and)\s*moon|s(&|and)m|sm)$/i, field: 'setName',
values: ['Sun & Moon', 'Guardians Rising', 'Burning Shadows', 'Crimson Invasion',
'Ultra Prism', 'Forbidden Light', 'Celestial Storm', 'Dragon Majesty',
'Lost Thunder', 'Team Up', 'Unbroken Bonds', 'Unified Minds',
'Hidden Fates', 'Cosmic Eclipse', 'Detective Pikachu'] },
{ re: /^(sword\s*(&|and)\s*shield|s(&|and)s|swsh)$/i, field: 'setName',
values: ['Sword & Shield', 'Rebel Clash', 'Darkness Ablaze', 'Vivid Voltage',
'Battle Styles', 'Chilling Reign', 'Evolving Skies', 'Fusion Strike',
'Brilliant Stars', 'Astral Radiance', 'Pokemon GO', 'Lost Origin',
'Silver Tempest', 'Crown Zenith'] },
// ── Card type shorthands ──────────────────────────────────────────────
{ re: /^trainers?$/i, field: 'cardType', values: ['Trainer'] },
{ re: /^supporters?$/i, field: 'cardType', values: ['Supporter'] },
{ re: /^stadiums?$/i, field: 'cardType', values: ['Stadium'] },
{ re: /^items?$/i, field: 'cardType', values: ['Item'] },
{ re: /^(energys?|energies)$/i, field: 'cardType', values: ['Energy'] },
// ── Rarity shorthands ─────────────────────────────────────────────────
{ re: /^promos?$/i, field: 'rarityName', values: ['Promo'] },
];
let resolvedQuery = query; let resolvedQuery = query;
let queryFilter = ''; let queryFilter = '';
for (const alias of ALIAS_FILTERS) { if (EREADER_RE.test(query.trim())) {
if (alias.re.test(query.trim())) { resolvedQuery = '';
resolvedQuery = ''; queryFilter = `setName:=[${EREADER_SETS.map(s => '`' + s + '`').join(',')}]`;
queryFilter = `${alias.field}:=[${alias.values.map(s => '`' + s + '`').join(',')}]`;
break;
}
} }
const filters = Array.from(formData.entries()) const filters = Array.from(formData.entries())
@@ -156,10 +118,7 @@ if (start === 0) {
const searchRequests = { searches: searchArray }; const searchRequests = { searches: searchArray };
const commonSearchParams = { const commonSearchParams = {
q: resolvedQuery, q: resolvedQuery,
query_by: 'content,setName,setCode,productName,Artist', query_by: 'content,setName,productLineName,rarityName,energyType,cardType'
query_by_weights: '10,6,8,9,8',
num_typos: '2,1,0,1,2',
prefix: 'true,true,false,false,false',
}; };
// use typesense to search for cards matching the query and return the productIds of the results // use typesense to search for cards matching the query and return the productIds of the results
@@ -317,46 +276,42 @@ const facets = searchResults.results.slice(1).map((result: any) => {
} }
{pokemon.length === 0 && ( {pokemon.length === 0 && (
<div id="notfound" class="mt-4 h6" hx-swap-oob="true"> <div id="notfound" hx-swap-oob="true">
No cards found! Please modify your search and try again. Pokemon not found
</div> </div>
)} )}
{pokemon.map((card: any, i: number) => ( {pokemon.map((card:any) => (
<div class="col">
<div class="col equal-height-col"> <div class="inventory-button position-relative float-end shadow-filter text-center d-none">
<div class="inventory-button position-relative float-end shadow-filter text-center d-none"> <div class="inventory-label pt-2">+/-</div>
<div class="inventory-label pt-2">+/-</div>
</div>
<div class="card-trigger position-relative" data-card-id={card.cardId} hx-get={`/partials/card-modal?cardId=${card.cardId}`} hx-target="#cardModal" hx-trigger="click" data-bs-toggle="modal" data-bs-target="#cardModal" onclick="const cardTitle = this.querySelector('#cardImage').getAttribute('alt'); dataLayer.push({'event': 'virtualPageview', 'pageUrl': this.getAttribute('hx-get'), 'pageTitle': cardTitle, 'previousUrl': '/pokemon'});">
<div class="image-grow rounded-4 card-image h-100" data-energy={card.energyType} data-rarity={card.rarityName} data-variant={card.variant} data-name={card.productName}>
<img src={`/static/cards/${card.productId}.jpg`} alt={card.productName} id="cardImage" loading="lazy" decoding="async" class="img-fluid rounded-4 mb-2 w-100" onerror="this.onerror=null; this.src='/static/cards/default.jpg'; this.closest('.image-grow')?.setAttribute('data-default','true')"/>
<span class="position-absolute top-50 start-0 d-inline medium-icon"><FirstEditionIcon edition={card?.variant} /></span>
<div class="holo-shine"></div>
<div class="holo-glare"></div>
</div> </div>
</div> <div class="card-trigger position-relative" data-card-id={card.cardId} hx-get={`/partials/card-modal?cardId=${card.cardId}`} hx-target="#cardModal" hx-trigger="click" data-bs-toggle="modal" data-bs-target="#cardModal" onclick="const cardTitle = this.querySelector('#cardImage').getAttribute('alt'); dataLayer.push({'event': 'virtualPageview', 'pageUrl': this.getAttribute('hx-get'), 'pageTitle': cardTitle, 'previousUrl': '/pokemon'});">
<div class="row row-cols-5 gx-1 price-row mb-2"> <div class="image-grow rounded-4 card-image" data-energy={card.energyType} data-rarity={card.rarityName} data-variant={card.variant} data-name={card.productName}><img src={`/cards/${card.productId}.jpg`} alt={card.productName} id="cardImage" loading="lazy" decoding="async" class="img-fluid rounded-4 mb-2 w-100" onerror="this.onerror=null; this.src='/cards/default.jpg'; this.closest('.image-grow')?.setAttribute('data-default','true')"/><span class="position-absolute top-50 start-0 d-inline medium-icon"><FirstEditionIcon edition={card?.variant} /></span>
{conditionOrder.map((condition) => ( <div class="holo-shine"></div>
<div class="col price-label ps-1"> <div class="holo-glare"></div>
{conditionShort(condition)} </div>
<br />{formatPrice(condition, card.skus)} </div>
</div> <div class="row row-cols-5 gx-1 price-row mb-2">
))} {conditionOrder.map((condition) => (
</div> <div class="col price-label ps-1">
<div class="h5 my-0">{card.productName}</div> { conditionShort(condition) }
<div class="d-flex flex-row lh-1 mt-1 justify-content-between"> <br />{formatPrice(condition, card.skus)}
<div class="text-secondary flex-grow-1"><span class="d-none d-lg-flex">{card.setName}</span><span class="d-flex d-lg-none">{card.setCode}</span></div> </div>
<div class="text-body-tertiary">{card.number}</div> ))}
<span class="ps-2 small-icon"><RarityIcon rarity={card.rarityName} /></span> </div>
</div> <div class="h5 my-0">{card.productName}</div>
<div class="text-body-tertiary">{card.variant}</div> <div class="d-flex flex-row lh-1 mt-1 justify-content-between">
<span class="d-none">{card.productId}</span> <div class="text-secondary flex-grow-1 d-none d-lg-flex">{card.setName}</div>
<div class="text-body-tertiary">{card.number}</div>
<span class="ps-2 small-icon"><RarityIcon rarity={card.rarityName} /></span>
</div>
<div class="text-body-tertiary">{card.variant}</div><span class="d-none">{card.productId}</span>
</div> </div>
</>
))} ))}
{start + 20 < totalHits && {start + 20 < totalHits &&
<div hx-post="/partials/cards" hx-trigger="revealed" hx-include="#searchform" hx-target="#cardGrid" hx-swap="beforeend" hx-on--after-request="afterUpdate(event)"> <div hx-post="/partials/cards" hx-trigger="revealed" hx-include="#searchform" hx-target="#cardGrid" hx-swap="beforeend" hx-on--after-request="afterUpdate(event)">
Loading... Loading...
</div> </div>
} }

View File

@@ -1,5 +1,5 @@
{ {
"extends": "astro/tsconfigs/strict", "extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "src/**/*"], "include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"] "exclude": ["dist"]
} }