Files
pokemon/src/pages/dashboard.astro

1189 lines
38 KiB
Plaintext
Raw Normal View History

2026-03-25 08:42:17 -04:00
---
import Layout from "../layouts/Main.astro";
import NavBar from "../components/NavBar.astro";
import NavItems from "../components/NavItems.astro";
import Footer from "../components/Footer.astro";
import FirstEditionIcon from "../components/FirstEditionIcon.astro";
// Mock inventory using the same schema as the Typesense cards collection.
// skus mirror the real shape: marketPrice is in cents (÷100 = dollars).
const inventory = [
{
productId: "3830",
productName: "Charizard",
setName: "Base Set",
setCode: "BS",
number: "4/102",
rarityName: "Rare Holo",
energyType: "Fire",
variant: "1st Edition",
qty: 2,
purchasePrice: 32000,
skus: [
{ condition: "Near Mint", marketPrice: 40000 },
{ condition: "Lightly Played", marketPrice: 31000 },
{ condition: "Moderately Played", marketPrice: 22000 },
{ condition: "Heavily Played", marketPrice: 14000 },
{ condition: "Damaged", marketPrice: 8500 },
],
},
{
productId: "3959",
productName: "Pikachu",
setName: "Shining Legends",
setCode: "SLG",
number: "SM70",
rarityName: "Promo",
energyType: "Lightning",
variant: "Normal",
qty: 5,
purchasePrice: 1500,
skus: [
{ condition: "Near Mint", marketPrice: 2000 },
{ condition: "Lightly Played", marketPrice: 1500 },
{ condition: "Moderately Played", marketPrice: 1100 },
{ condition: "Heavily Played", marketPrice: 700 },
{ condition: "Damaged", marketPrice: 400 },
],
},
{
productId: "3729",
productName: "Mewtwo",
setName: "Fossil",
setCode: "FO",
number: "10/62",
rarityName: "Rare Holo",
energyType: "Psychic",
variant: "Normal",
qty: 1,
purchasePrice: 12000,
skus: [
{ condition: "Near Mint", marketPrice: 13000 },
{ condition: "Lightly Played", marketPrice: 10000 },
{ condition: "Moderately Played", marketPrice: 7200 },
{ condition: "Heavily Played", marketPrice: 4500 },
{ condition: "Damaged", marketPrice: 2500 },
],
},
{
productId: "4114",
productName: "Umbreon VMAX",
setName: "Evolving Skies",
setCode: "EVS",
number: "215/203",
rarityName: "Secret Rare",
energyType: "Darkness",
variant: "Alternate Art",
qty: 1,
purchasePrice: 8500,
skus: [
{ condition: "Near Mint", marketPrice: 11500 },
{ condition: "Lightly Played", marketPrice: 9000 },
{ condition: "Moderately Played", marketPrice: 6500 },
{ condition: "Heavily Played", marketPrice: 4000 },
{ condition: "Damaged", marketPrice: 2000 },
],
},
{
productId: "3887",
productName: "Gyarados",
setName: "Hidden Fates",
setCode: "HIF",
number: "SV19/SV94",
rarityName: "Shiny Holo Rare",
energyType: "Water",
variant: "Shiny",
qty: 3,
purchasePrice: 2500,
skus: [
{ condition: "Near Mint", marketPrice: 3000 },
{ condition: "Lightly Played", marketPrice: 2300 },
{ condition: "Moderately Played", marketPrice: 1600 },
{ condition: "Heavily Played", marketPrice: 900 },
{ condition: "Damaged", marketPrice: 500 },
],
},
{
productId: "4201",
productName: "Rayquaza VMAX",
setName: "Evolving Skies",
setCode: "EVS",
number: "218/203",
rarityName: "Secret Rare",
energyType: "Dragon",
variant: "Alternate Art",
qty: 2,
purchasePrice: 6500,
skus: [
{ condition: "Near Mint", marketPrice: 8800 },
{ condition: "Lightly Played", marketPrice: 7000 },
{ condition: "Moderately Played", marketPrice: 5000 },
{ condition: "Heavily Played", marketPrice: 3200 },
{ condition: "Damaged", marketPrice: 1800 },
],
},
{
productId: "4055",
productName: "Eevee",
setName: "Sword & Shield",
setCode: "SSH",
number: "TG07/TG30",
rarityName: "Trainer Gallery",
energyType: "Colorless",
variant: "Normal",
qty: 10,
purchasePrice: 800,
skus: [
{ condition: "Near Mint", marketPrice: 900 },
{ condition: "Lightly Played", marketPrice: 700 },
{ condition: "Moderately Played", marketPrice: 500 },
{ condition: "Heavily Played", marketPrice: 300 },
{ condition: "Damaged", marketPrice: 150 },
],
},
{
productId: "3991",
productName: "Lugia V",
setName: "Silver Tempest",
setCode: "SIT",
number: "186/195",
rarityName: "Ultra Rare",
energyType: "Colorless",
variant: "Alternate Art",
qty: 1,
purchasePrice: 4500,
skus: [
{ condition: "Near Mint", marketPrice: 5800 },
{ condition: "Lightly Played", marketPrice: 4600 },
{ condition: "Moderately Played", marketPrice: 3200 },
{ condition: "Heavily Played", marketPrice: 2000 },
{ condition: "Damaged", marketPrice: 1000 },
],
},
{
productId: "4301",
productName: "Blastoise",
setName: "Base Set",
setCode: "BS",
number: "2/102",
rarityName: "Rare Holo",
energyType: "Water",
variant: "Shadowless",
qty: 1,
purchasePrice: 18000,
skus: [
{ condition: "Near Mint", marketPrice: 24000 },
{ condition: "Lightly Played", marketPrice: 18500 },
{ condition: "Moderately Played", marketPrice: 13000 },
{ condition: "Heavily Played", marketPrice: 8000 },
{ condition: "Damaged", marketPrice: 4500 },
],
},
{
productId: "4302",
productName: "Venusaur",
setName: "Base Set",
setCode: "BS",
number: "15/102",
rarityName: "Rare Holo",
energyType: "Grass",
variant: "1st Edition",
qty: 1,
purchasePrice: 22000,
skus: [
{ condition: "Near Mint", marketPrice: 19500 },
{ condition: "Lightly Played", marketPrice: 15000 },
{ condition: "Moderately Played", marketPrice: 10500 },
{ condition: "Heavily Played", marketPrice: 6500 },
{ condition: "Damaged", marketPrice: 3500 },
],
},
{
productId: "4303",
productName: "Espeon VMAX",
setName: "Evolving Skies",
setCode: "EVS",
number: "205/203",
rarityName: "Secret Rare",
energyType: "Psychic",
variant: "Alternate Art",
qty: 2,
purchasePrice: 7000,
skus: [
{ condition: "Near Mint", marketPrice: 9200 },
{ condition: "Lightly Played", marketPrice: 7300 },
{ condition: "Moderately Played", marketPrice: 5200 },
{ condition: "Heavily Played", marketPrice: 3300 },
{ condition: "Damaged", marketPrice: 1600 },
],
},
{
productId: "4304",
productName: "Gengar VMAX",
setName: "Fusion Strike",
setCode: "FST",
number: "271/264",
rarityName: "Secret Rare",
energyType: "Psychic",
variant: "Alternate Art",
qty: 1,
purchasePrice: 5500,
skus: [
{ condition: "Near Mint", marketPrice: 4800 },
{ condition: "Lightly Played", marketPrice: 3800 },
{ condition: "Moderately Played", marketPrice: 2700 },
{ condition: "Heavily Played", marketPrice: 1700 },
{ condition: "Damaged", marketPrice: 900 },
],
},
{
productId: "4305",
productName: "Pikachu VMAX",
setName: "Vivid Voltage",
setCode: "VIV",
number: "188/185",
rarityName: "Secret Rare",
energyType: "Lightning",
variant: "Rainbow Rare",
qty: 3,
purchasePrice: 3200,
skus: [
{ condition: "Near Mint", marketPrice: 4100 },
{ condition: "Lightly Played", marketPrice: 3200 },
{ condition: "Moderately Played", marketPrice: 2300 },
{ condition: "Heavily Played", marketPrice: 1400 },
{ condition: "Damaged", marketPrice: 750 },
],
},
{
productId: "4306",
productName: "Alakazam",
setName: "Base Set",
setCode: "BS",
number: "1/102",
rarityName: "Rare Holo",
energyType: "Psychic",
variant: "Shadowless",
qty: 2,
purchasePrice: 9500,
skus: [
{ condition: "Near Mint", marketPrice: 12000 },
{ condition: "Lightly Played", marketPrice: 9300 },
{ condition: "Moderately Played", marketPrice: 6600 },
{ condition: "Heavily Played", marketPrice: 4100 },
{ condition: "Damaged", marketPrice: 2200 },
],
},
{
productId: "4307",
productName: "Sylveon VMAX",
setName: "Evolving Skies",
setCode: "EVS",
number: "212/203",
rarityName: "Secret Rare",
energyType: "Psychic",
variant: "Alternate Art",
qty: 1,
purchasePrice: 6800,
skus: [
{ condition: "Near Mint", marketPrice: 8500 },
{ condition: "Lightly Played", marketPrice: 6700 },
{ condition: "Moderately Played", marketPrice: 4800 },
{ condition: "Heavily Played", marketPrice: 3000 },
{ condition: "Damaged", marketPrice: 1500 },
],
},
{
productId: "4308",
productName: "Mew VMAX",
setName: "Fusion Strike",
setCode: "FST",
number: "269/264",
rarityName: "Secret Rare",
energyType: "Psychic",
variant: "Alternate Art",
qty: 2,
purchasePrice: 4200,
skus: [
{ condition: "Near Mint", marketPrice: 5600 },
{ condition: "Lightly Played", marketPrice: 4400 },
{ condition: "Moderately Played", marketPrice: 3100 },
{ condition: "Heavily Played", marketPrice: 2000 },
{ condition: "Damaged", marketPrice: 1000 },
],
},
{
productId: "4309",
productName: "Darkrai VSTAR",
setName: "Astral Radiance",
setCode: "ASR",
number: "189/189",
rarityName: "Secret Rare",
energyType: "Darkness",
variant: "Gold",
qty: 1,
purchasePrice: 3800,
skus: [
{ condition: "Near Mint", marketPrice: 3200 },
{ condition: "Lightly Played", marketPrice: 2500 },
{ condition: "Moderately Played", marketPrice: 1800 },
{ condition: "Heavily Played", marketPrice: 1100 },
{ condition: "Damaged", marketPrice: 600 },
],
},
{
productId: "4310",
productName: "Leafeon VSTAR",
setName: "Pokémon GO",
setCode: "PGO",
number: "076/078",
rarityName: "Ultra Rare",
energyType: "Grass",
variant: "Normal",
qty: 4,
purchasePrice: 1200,
skus: [
{ condition: "Near Mint", marketPrice: 1800 },
{ condition: "Lightly Played", marketPrice: 1400 },
{ condition: "Moderately Played", marketPrice: 1000 },
{ condition: "Heavily Played", marketPrice: 600 },
{ condition: "Damaged", marketPrice: 300 },
],
},
{
productId: "4311",
productName: "Snorlax",
setName: "Celebrations",
setCode: "CEL",
number: "005/025",
rarityName: "Rare Holo",
energyType: "Colorless",
variant: "Classic Collection",
qty: 6,
purchasePrice: 600,
skus: [
{ condition: "Near Mint", marketPrice: 750 },
{ condition: "Lightly Played", marketPrice: 580 },
{ condition: "Moderately Played", marketPrice: 420 },
{ condition: "Heavily Played", marketPrice: 260 },
{ condition: "Damaged", marketPrice: 130 },
],
},
{
productId: "4312",
productName: "Articuno",
setName: "Fossil",
setCode: "FO",
number: "2/62",
rarityName: "Rare Holo",
energyType: "Water",
variant: "1st Edition",
qty: 1,
purchasePrice: 14000,
skus: [
{ condition: "Near Mint", marketPrice: 11500 },
{ condition: "Lightly Played", marketPrice: 9000 },
{ condition: "Moderately Played", marketPrice: 6400 },
{ condition: "Heavily Played", marketPrice: 4000 },
{ condition: "Damaged", marketPrice: 2200 },
],
},
{
productId: "4313",
productName: "Radiant Charizard",
setName: "Pokemon GO",
setCode: "PGO",
number: "011/078",
rarityName: "Radiant Rare",
energyType: "Fire",
variant: "Normal",
qty: 2,
purchasePrice: 2800,
skus: [
{ condition: "Near Mint", marketPrice: 4200 },
{ condition: "Lightly Played", marketPrice: 3300 },
{ condition: "Moderately Played", marketPrice: 2400 },
{ condition: "Heavily Played", marketPrice: 1500 },
{ condition: "Damaged", marketPrice: 800 },
],
},
{
productId: "4314",
productName: "Giratina VSTAR",
setName: "Lost Origin",
setCode: "LOR",
number: "196/196",
rarityName: "Secret Rare",
energyType: "Dragon",
variant: "Alternate Art",
qty: 1,
purchasePrice: 5200,
skus: [
{ condition: "Near Mint", marketPrice: 7800 },
{ condition: "Lightly Played", marketPrice: 6100 },
{ condition: "Moderately Played", marketPrice: 4400 },
{ condition: "Heavily Played", marketPrice: 2800 },
{ condition: "Damaged", marketPrice: 1400 },
],
},
];
// Helpers
const nmPrice = (card: typeof inventory[0]) => (card.skus[0]?.marketPrice ?? 0) / 100;
const nmPurchase = (card: typeof inventory[0]) => card.purchasePrice / 100;
const gain = (card: typeof inventory[0]) => nmPrice(card) - nmPurchase(card);
const totalQty = inventory.reduce((s, c) => s + c.qty, 0);
const totalValue = inventory.reduce((s, c) => s + nmPrice(c) * c.qty, 0);
const totalGain = inventory.reduce((s, c) => s + gain(c) * c.qty, 0);
---
<Layout title="Inventory Dashboard">
<NavBar slot="navbar">
<NavItems slot="navItems" />
</NavBar>
<div class="row g-0" style="min-height: calc(100vh - 120px)" slot="page">
<aside class="col-12 col-md-2 border-end border-secondary bg-dark p-3 d-flex flex-column gap-3">
<div class="d-flex justify-content-between align-items-center">
<h6 class="mb-0 text-uppercase text-secondary fw-bold ls-wide" style="letter-spacing:.08em">Catalogs</h6>
<button
class="btn btn-sm btn-info fs-7"
title="New catalog"
type="button"
data-bs-toggle="modal"
data-bs-target="#newCatalogModal"
>+ New</button>
</div>
<ul id="catalogList" class="list-group list-group-flush">
<li
class="list-group-item list-group-item-action bg-transparent text-light border-0 rounded px-2 py-2 d-flex align-items-center justify-content-between active"
data-catalog="all"
role="button"
style="cursor:pointer"
>
<span class="d-flex align-items-center gap-2">
View all cards
</span>
<span class="badge rounded-pill text-bg-secondary small">{totalQty}</span>
</li>
{["Case Cards", "Japanese Singles", "Bulk"].map((name) => (
<li
class="ms-2 list-group-item list-group-item-action bg-transparent text-light border-0 rounded px-2 py-2 d-flex align-items-center gap-2"
data-catalog={name}
role="button"
style="cursor:pointer"
>
{name}
</li>
))}
</ul>
<div class="mt-auto pt-3 border-top border-secondary small text-secondary">
<div class="d-flex justify-content-between mb-1"><span>Total Cards</span><span class="text-light fw-semibold">{totalQty}</span></div>
<div class="d-flex justify-content-between mb-1"><span>Market Value</span><span class="text-success fw-semibold">${totalValue.toFixed(0)}</span></div>
<div class="d-flex justify-content-between"><span>Profit/Loss</span><span class={`fw-semibold ${totalGain >= 0 ? "text-success" : "text-danger"}`}>{totalGain >= 0 ? "+" : ""}${Math.abs(totalGain).toFixed(0)}</span></div>
</div>
</aside>
<main class="col-12 col-md-10 p-4">
<div class="d-flex flex-wrap gap-2 align-items-center mb-4">
<div class="d-flex align-items-center gap-1">
<button id="btnGrid" type="button" class="btn btn-sm btn-link text-secondary px-1 view-toggle-btn active" title="Images view">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M1 2.5A1.5 1.5 0 0 1 2.5 1h3A1.5 1.5 0 0 1 7 2.5v3A1.5 1.5 0 0 1 5.5 7h-3A1.5 1.5 0 0 1 1 5.5v-3zm8 0A1.5 1.5 0 0 1 10.5 1h3A1.5 1.5 0 0 1 15 2.5v3A1.5 1.5 0 0 1 13.5 7h-3A1.5 1.5 0 0 1 9 5.5v-3zm-8 8A1.5 1.5 0 0 1 2.5 9h3A1.5 1.5 0 0 1 7 10.5v3A1.5 1.5 0 0 1 5.5 15h-3A1.5 1.5 0 0 1 1 13.5v-3zm8 0A1.5 1.5 0 0 1 10.5 9h3A1.5 1.5 0 0 1 15 10.5v3A1.5 1.5 0 0 1 13.5 15h-3A1.5 1.5 0 0 1 9 13.5v-3z"/>
</svg>
<span class="small ms-1">Images</span>
</button>
<button id="btnTable" type="button" class="btn btn-sm btn-link text-secondary px-1 view-toggle-btn" title="List view">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M2.5 12a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm0-4a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm0-4a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5z"/>
</svg>
<span class="small ms-1">List</span>
</button>
</div>
<div class="vr opacity-25 mx-1"></div>
<button
type="button"
class="btn btn-sm btn-info"
data-bs-toggle="modal"
data-bs-target="#addCardModal"
>+ Add Card</button>
<button
type="button"
class="btn btn-sm btn-outline-light"
data-bs-toggle="modal"
data-bs-target="#bulkImportModal"
>⬆ CSV Import</button>
<div class="ms-auto position-relative">
<input
id="inventorySearch"
class="form-control form-control-sm bg-dark text-light border-secondary"
placeholder="Search inventory…"
style="min-width:200px; padding-right:2rem"
/>
<button
id="clearSearch"
type="button"
class="btn btn-sm p-0 position-absolute top-50 end-0 translate-middle-y me-2 text-secondary d-none"
style="line-height:1"
aria-label="Clear search"
>✕</button>
</div>
</div>
<div id="inventoryView">
<div id="gridView" class="row g-4 row-cols-2 row-cols-md-3 row-cols-xl-4 row-cols-xxxl-5">
{inventory.map(card => {
const market = nmPrice(card);
const purchase = nmPurchase(card);
const diff = market - purchase;
const pct = purchase > 0 ? (diff / purchase) * 100 : 0;
const isGain = diff >= 0;
return (
<div class="col">
<article class="inv-grid-card">
<div
class="card-trigger position-relative inv-grid-media"
data-card-id={card.productId}
data-bs-toggle="modal"
data-bs-target="#cardModal"
>
<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}
loading="lazy"
decoding="async"
class="img-fluid rounded-4 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" style="z-index:4">
<FirstEditionIcon edition={card.variant} />
</span>
</div>
</div>
<div class="inv-grid-body">
<div class="inv-grid-main">
<div class="h5">{card.productName}</div>
<div class="inv-grid-meta">
<div class="p">{card.setName}</div>
</div>
</div>
<div class="inv-grid-price">
<div class={`inv-grid-trend ${isGain ? "up" : "down"}`}>
<span class="inv-grid-arrow">{isGain ? "▲" : "▼"}</span>
<span class="h6">${market.toFixed(2)}</span>
</div>
<div class={`inv-grid-delta fs-6 ${isGain ? "up" : "down"}`}>
{isGain ? "+" : "-"}${Math.abs(diff).toFixed(2)} ({isGain ? "+" : "-"}{Math.abs(pct).toFixed(2)}%)
</div>
<div class="d-flex justify-content-between align-items-center gap-3 flex-wrap">
<div class="d-flex align-items-center gap-2">
<div class="btn-group" role="group" aria-label="Quantity controls">
<button type="button" class="btn btn-outline-secondary btn-sm"></button>
<button type="button" class="btn btn-outline-secondary btn-sm" tabindex="-1">2</button>
<button type="button" class="btn btn-outline-secondary btn-sm">+</button>
</div>
</div>
</div>
</div>
</article>
</div>
);
})}
</div>
<div id="tableView" style="display:none">
<div class="inv-list-wrap">
<table class="table align-middle mb-0 inv-list-table">
<tbody id="inventoryRows">
{inventory.map(card => {
const market = nmPrice(card);
const purchase = nmPurchase(card);
const diff = market - purchase;
const pct = purchase > 0 ? (diff / purchase) * 100 : 0;
const isGain = diff >= 0;
return (
<tr class="inv-list-row">
<td class="inv-list-cardcell">
<div class="inv-list-card">
<div
class="inv-list-thumb card-trigger"
data-card-id={card.productId}
data-bs-toggle="modal"
data-bs-target="#cardModal"
>
<img
src={`/cards/${card.productId}.jpg`}
alt={card.productName}
loading="lazy"
decoding="async"
onerror="this.onerror=null;this.src='/cards/default.jpg';"
/>
</div>
<div class="inv-list-info">
<div
class="inv-list-name"
data-card-id={card.productId}
data-bs-toggle="modal"
data-bs-target="#cardModal"
style="cursor:pointer"
>
{card.productName}
</div>
<div class="inv-list-meta">
<div class="inv-list-setlink">{card.setName}</div>
<div>{card.rarityName}</div>
<div>{card.number}</div>
</div>
<div class="inv-list-condition">
<span>Near Mint</span>
<span>•</span>
<span>{card.variant !== "Normal" ? card.variant : "Holofoil"}</span>
</div>
</div>
<div class="inv-list-right">
<div class={`inv-list-price-line ${isGain ? "up" : "down"}`}>
<span class="inv-grid-arrow small">{isGain ? "▲" : "▼"}</span>
<span class="inv-list-price">${market.toFixed(2)}</span>
</div>
<div class={`inv-list-delta ${isGain ? "up" : "down"}`}>
{isGain ? "+" : "-"}${Math.abs(diff).toFixed(2)} ({isGain ? "+" : "-"}{Math.abs(pct).toFixed(2)}%)
</div>
<div class="inv-list-qty">Qty: {card.qty}</div>
</div>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div class="text-secondary small mt-2 ps-1" id="rowCount"></div>
</div>
</div>
</main>
</div>
<div class="modal fade" id="newCatalogModal" tabindex="-1" aria-labelledby="newCatalogLabel" aria-modal="true" role="dialog">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content bg-dark text-light border border-secondary">
<div class="modal-header border-secondary">
<h5 class="modal-title" id="newCatalogLabel">Create Catalog</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<label class="form-label small text-secondary text-uppercase fw-semibold" for="catalogNameInput">Catalog Name</label>
<input id="catalogNameInput" type="text" class="form-control bg-dark-subtle text-light border-secondary" placeholder="e.g. Japanese Holos" />
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" id="createCatalogBtn">Create</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="bulkImportModal" tabindex="-1" aria-labelledby="bulkImportLabel" aria-modal="true" role="dialog">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content bg-dark text-light border border-secondary">
<div class="modal-header border-secondary">
<h5 class="modal-title" id="bulkImportLabel">Bulk CSV Import</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p class="small text-secondary mb-3">
Upload a CSV exported from Collectr, TCGPlayer, or any marketplace. Columns: <code>name, set, condition, qty, price, market</code>.
</p>
<label class="form-label small text-secondary text-uppercase fw-semibold" for="csvFileInput">Choose File</label>
<input id="csvFileInput" type="file" accept=".csv" class="form-control bg-dark-subtle text-light border-secondary" />
<div id="csvPreview" class="mt-3 d-none">
<p class="small text-secondary fw-semibold mb-1">Preview</p>
<div class="border border-secondary rounded p-2 small text-secondary" id="csvPreviewContent">—</div>
</div>
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" id="csvUploadBtn">Upload &amp; Preview</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="inventoryEditModal" tabindex="-1" aria-labelledby="inventoryEditLabel" aria-modal="true" role="dialog">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content bg-dark text-light border border-secondary">
<div class="modal-header border-secondary">
<h5 class="modal-title" id="inventoryEditLabel">Edit Inventory</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="inventoryEditBody">
<p class="text-secondary small">Select a card to edit its quantity and purchase price.</p>
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success">Save</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="addCardModal" tabindex="-1" aria-labelledby="addCardLabel" aria-modal="true" role="dialog">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content bg-dark text-light border border-secondary">
<div class="modal-header border-secondary">
<h5 class="modal-title" id="addCardLabel">Add Card</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<input type="text" class="form-control bg-dark-subtle text-light border-secondary mb-3" placeholder="Search card name…" id="addCardSearch" />
<p class="text-secondary small">Search results will appear here. Connect to your card database API to enable live search.</p>
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" disabled>Add Selected</button>
</div>
</div>
</div>
</div>
<Footer slot="footer" />
</Layout>
<style>
.view-toggle-btn {
text-decoration: none;
color: var(--bs-secondary-color) !important;
}
.view-toggle-btn.active {
color: var(--bs-body-color) !important;
}
.view-toggle-btn.active svg,
.view-toggle-btn:hover svg {
color: var(--bs-body-color);
}
#catalogList .list-group-item.active {
background-color: rgba(var(--bs-danger-rgb), .15) !important;
color: rgba(var(--bs-danger-rgb), 1) !important;
border-left: 2px solid var(--bs-danger) !important;
}
#gridView {
row-gap: 1.5rem;
}
.inv-grid-card {
position: relative;
height: 100%;
display: flex;
flex-direction: column;
}
.inv-grid-media {
cursor: pointer;
}
.inv-grid-body {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
padding: .85rem .15rem 0;
}
.inv-grid-main {
min-width: 0;
flex: 1;
}
.inv-grid-title {
font-size: 1.9rem;
line-height: 1.05;
font-weight: 500;
margin-bottom: .4rem;
color: var(--bs-emphasis-color);
}
.inv-grid-meta {
display: flex;
flex-direction: column;
gap: .2rem;
}
.inv-grid-submeta {
display: flex;
flex-wrap: wrap;
gap: .35rem;
font-size: .9rem;
color: var(--bs-secondary-color);
}
.inv-grid-price {
min-width: 112px;
text-align: right;
flex-shrink: 0;
color: #f3f3f3 !important;
}
.inv-grid-trend,
.inv-list-price-line {
display: flex;
align-items: center;
justify-content: flex-end;
gap: .35rem;
font-weight: 700;
line-height: 1.1;
}
.inv-grid-value,
.inv-list-price {
font-size: 1.15rem;
color: #111;
}
.inv-grid-trend.up .inv-grid-arrow,
.inv-list-price-line.up .inv-grid-arrow {
color: #2e7d32;
}
.inv-grid-trend.down,
.inv-list-price-line.down {
color: #c62828;
}
.inv-grid-delta,
.inv-list-delta {
margin-top: .2rem;
font-size: .86rem;
line-height: 1.15;
}
.inv-grid-delta.up,
.inv-list-delta.up {
color: #2e7d32;
}
.inv-grid-delta.down,
.inv-list-delta.down {
color: #c62828;
}
.inv-grid-qty,
.inv-list-qty {
margin-top: .35rem;
font-size: .9rem;
color: #1ea7a1;
}
.inv-grid-cart,
.inv-list-cart {
position: absolute;
right: .35rem;
bottom: .1rem;
width: 2.15rem;
height: 2.15rem;
border-radius: 999px;
border: 1px solid #4cb7b3;
background: transparent;
color: #38a9a5;
display: inline-flex;
align-items: center;
justify-content: center;
transition: .15s ease;
}
.inv-grid-cart:hover,
.inv-list-cart:hover {
background: rgba(76, 183, 179, .08);
color: #2a9a96;
}
#tableView {
background: transparent;
}
.inv-list-wrap {
border-radius: 0;
overflow: visible;
}
.inv-list-table {
--bs-table-bg: transparent;
--bs-table-hover-bg: transparent;
--bs-table-color: inherit;
margin: 0;
}
.inv-list-table tbody,
.inv-list-table tr,
.inv-list-table td {
border: 0 !important;
background: transparent !important;
}
.inv-list-row + .inv-list-row .inv-list-cardcell {
border-top: 1px solid rgba(0, 0, 0, .08) !important;
}
.inv-list-cardcell {
padding: 0;
}
.inv-list-card {
position: relative;
display: flex;
align-items: center;
gap: 1rem;
min-height: 116px;
padding: .8rem 4.5rem .8rem .35rem;
background: #f3f3f3;
border: 1px solid rgba(0, 0, 0, .08);
border-radius: .35rem;
}
.inv-list-thumb {
width: 70px;
flex: 0 0 70px;
cursor: pointer;
}
.inv-list-thumb img {
width: 100%;
display: block;
border-radius: .25rem;
}
.inv-list-info {
min-width: 0;
flex: 1;
}
.inv-list-name {
font-size: 1.1rem;
font-weight: 700;
color: #111827;
margin-bottom: .35rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: none;
}
.inv-list-name:hover {
text-decoration: underline;
}
.inv-list-meta {
display: flex;
flex-direction: column;
gap: .1rem;
font-size: .95rem;
color: #4b5563;
}
.inv-list-setlink {
color: #5b6f8f;
text-decoration: underline;
text-underline-offset: 2px;
}
.inv-list-condition {
margin-top: .35rem;
display: flex;
flex-wrap: wrap;
gap: .35rem;
font-size: .95rem;
color: #2b7a78;
}
.inv-list-right {
margin-left: auto;
min-width: 140px;
text-align: right;
padding-right: 1rem;
}
@media (max-width: 768px) {
.inv-grid-body {
flex-direction: column;
gap: .5rem;
}
.inv-grid-price {
min-width: 0;
text-align: left;
}
.inv-grid-trend {
justify-content: flex-start;
}
.inv-list-card {
align-items: flex-start;
padding-right: 3.75rem;
}
.inv-list-right {
min-width: 0;
padding-right: .25rem;
}
}
</style>
<script>
import * as bootstrap from "bootstrap";
document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll(".modal").forEach((el) => {
bootstrap.Modal.getOrCreateInstance(el);
});
document.querySelectorAll("#catalogList [data-catalog]").forEach((item) => {
item.addEventListener("click", () => {
document.querySelectorAll("#catalogList [data-catalog]").forEach((i) => i.classList.remove("active"));
item.classList.add("active");
const catalog = item.dataset.catalog ?? "all";
document.querySelectorAll("#gridView .col").forEach((col) => {
col.style.display = catalog === "all" ? "" : "none";
});
document.querySelectorAll("#inventoryRows tr").forEach((row) => {
row.style.display = catalog === "all" ? "" : "none";
});
updateRowCount();
});
});
const gridView = document.getElementById("gridView");
const tableView = document.getElementById("tableView");
const btnGrid = document.getElementById("btnGrid");
const btnTable = document.getElementById("btnTable");
const rowCount = document.getElementById("rowCount");
const tbody = document.getElementById("inventoryRows");
function showGrid() {
gridView.style.display = "";
tableView.style.display = "none";
btnGrid.classList.add("active");
btnTable.classList.remove("active");
}
function showTable() {
gridView.style.display = "none";
tableView.style.display = "";
btnGrid.classList.remove("active");
btnTable.classList.add("active");
updateRowCount();
}
btnGrid?.addEventListener("click", showGrid);
btnTable?.addEventListener("click", showTable);
const searchInput = document.getElementById("inventorySearch");
const clearBtn = document.getElementById("clearSearch");
let searchTimer;
searchInput?.addEventListener("input", () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
const term = searchInput.value.toLowerCase();
clearBtn.classList.toggle("d-none", !term);
tbody?.querySelectorAll("tr").forEach((row) => {
row.style.display = row.textContent.toLowerCase().includes(term) ? "" : "none";
});
updateRowCount();
}, 120);
});
clearBtn?.addEventListener("click", () => {
searchInput.value = "";
clearBtn.classList.add("d-none");
tbody?.querySelectorAll("tr").forEach((r) => {
r.style.display = "";
});
updateRowCount();
});
function updateRowCount() {
if (!rowCount || !tbody) return;
const visible = [...tbody.querySelectorAll("tr")].filter((r) => r.style.display !== "none").length;
rowCount.textContent = `Showing ${visible} card${visible !== 1 ? "s" : ""}`;
}
updateRowCount();
document.getElementById("createCatalogBtn")?.addEventListener("click", () => {
const input = document.getElementById("catalogNameInput");
const name = input.value.trim();
if (!name) {
input.focus();
return;
}
const li = document.createElement("li");
li.className =
"list-group-item list-group-item-action bg-transparent text-light border-0 rounded px-2 py-2 d-flex align-items-center gap-2";
li.setAttribute("data-catalog", name);
li.setAttribute("role", "button");
li.style.cursor = "pointer";
li.innerHTML = `<span class="text-secondary" style="font-size:.7rem">▸</span>${name}`;
li.addEventListener("click", () => {
document.querySelectorAll("#catalogList [data-catalog]").forEach((i) => i.classList.remove("active"));
li.classList.add("active");
});
document.getElementById("catalogList")?.appendChild(li);
input.value = "";
bootstrap.Modal.getInstance(document.getElementById("newCatalogModal"))?.hide();
});
document.getElementById("csvFileInput")?.addEventListener("change", (e) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
const lines = ev.target.result.split("\n").slice(0, 4);
const preview = document.getElementById("csvPreviewContent");
preview.textContent = lines.join("\n");
document.getElementById("csvPreview")?.classList.remove("d-none");
};
reader.readAsText(file);
});
});
</script>