Files
pokemon/src/pages/dashboard.astro

1048 lines
36 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 = [
{
2026-03-25 10:23:17 -04:00
productId: "42382",
2026-03-25 08:42:17 -04:00
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 },
],
},
{
2026-03-25 10:23:17 -04:00
productId: "146682",
2026-03-25 08:42:17 -04:00
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 },
],
},
{
2026-03-25 10:23:17 -04:00
productId: "246723",
2026-03-25 08:42:17 -04:00
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 },
],
},
{
2026-03-25 10:23:17 -04:00
productId: "197660",
2026-03-25 08:42:17 -04:00
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 },
],
},
{
2026-03-25 10:23:17 -04:00
productId: "246733",
2026-03-25 08:42:17 -04:00
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 },
],
},
{
2026-03-25 10:23:17 -04:00
productId: "264218",
2026-03-25 08:42:17 -04:00
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 },
],
},
{
2026-03-25 10:23:17 -04:00
productId: "451834",
2026-03-25 08:42:17 -04:00
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 },
],
},
{
2026-03-25 10:23:17 -04:00
productId: "106997",
2026-03-25 08:42:17 -04:00
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 },
],
},
{
2026-03-25 10:23:17 -04:00
productId: "253265",
2026-03-25 08:42:17 -04:00
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 },
],
},
{
2026-03-25 10:23:17 -04:00
productId: "253266",
2026-03-25 08:42:17 -04:00
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 },
],
},
{
2026-03-25 10:23:17 -04:00
productId: "226432",
2026-03-25 08:42:17 -04:00
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 },
],
},
{
2026-03-25 10:23:17 -04:00
productId: "253275",
2026-03-25 08:42:17 -04:00
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 },
],
},
{
2026-03-25 10:23:17 -04:00
productId: "478077",
2026-03-25 08:42:17 -04:00
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 },
],
},
{
2026-03-25 10:23:17 -04:00
productId: "477060",
2026-03-25 08:42:17 -04:00
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 },
],
},
{
2026-03-25 10:23:17 -04:00
productId: "478100",
2026-03-25 08:42:17 -04:00
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
2026-03-25 10:23:17 -04:00
class="btn btn-sm btn-success fs-7"
2026-03-25 08:42:17 -04:00
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"
2026-03-25 10:23:17 -04:00
class="btn btn-sm btn-vendor"
2026-03-25 08:42:17 -04:00
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"
2026-03-25 10:23:17 -04:00
>Bulk Import</button>
2026-03-25 08:42:17 -04:00
<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
2026-03-25 10:23:17 -04:00
class="rounded-4 card-image"
2026-03-25 08:42:17 -04:00
data-energy={card.energyType}
data-rarity={card.rarityName}
data-variant={card.variant}
data-name={card.productName}
>
<img
src={`static/cards/${card.productId}.jpg`}
2026-03-25 08:42:17 -04:00
alt={card.productName}
loading="lazy"
decoding="async"
class="img-fluid rounded-4 w-100"
onerror="this.onerror=null;this.src='static/cards/default.jpg';this.closest('.image-grow')?.setAttribute('data-default','true')"
2026-03-25 08:42:17 -04:00
/>
<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="d-flex flex-row justify-content-between my-1 align-items-center">
2026-03-25 10:23:17 -04:00
<input type="number" class="form-control text-center" style="max-width: 50%;" value="1" min="1" max="999" aria-label="Quantity input" aria-describedby="button-minus button-plus">
<div class="" aria-label="Edit controls">
<button type="button" class="btn btn-outline-warning btn-sm"><svg class="edit-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M100.4 417.2C104.5 402.6 112.2 389.3 123 378.5L304.2 197.3L338.1 163.4C354.7 180 389.4 214.7 442.1 267.4L476 301.3L442.1 335.2L260.9 516.4C250.2 527.1 236.8 534.9 222.2 539L94.4 574.6C86.1 576.9 77.1 574.6 71 568.4C64.9 562.2 62.6 553.3 64.9 545L100.4 417.2zM156 413.5C151.6 418.2 148.4 423.9 146.7 430.1L122.6 517L209.5 492.9C215.9 491.1 221.7 487.8 226.5 483.2L155.9 413.5zM510 267.4C493.4 250.8 458.7 216.1 406 163.4L372 129.5C398.5 103 413.4 88.1 416.9 84.6C430.4 71 448.8 63.4 468 63.4C487.2 63.4 505.6 71 519.1 84.6L554.8 120.3C568.4 133.9 576 152.3 576 171.4C576 190.5 568.4 209 554.8 222.5C551.3 226 536.4 240.9 509.9 267.4z"/></svg></button>
<button type="button" class="btn btn-outline-danger btn-sm"><svg class="delete-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M232.7 69.9L224 96L128 96C110.3 96 96 110.3 96 128C96 145.7 110.3 160 128 160L512 160C529.7 160 544 145.7 544 128C544 110.3 529.7 96 512 96L416 96L407.3 69.9C402.9 56.8 390.7 48 376.9 48L263.1 48C249.3 48 237.1 56.8 232.7 69.9zM512 208L128 208L149.1 531.1C150.7 556.4 171.7 576 197 576L443 576C468.3 576 489.3 556.4 490.9 531.1L512 208z"/></svg></button>
</div>
</div>
<div class="d-flex flex-row mt-1">
<div class="p small text-secondary">{card.setName}</div>
</div>
<div class="d-flex flex-row mt-1">
<div class="h5">{card.productName}</div>
</div>
<div class="d-flex flex-row mt-1 justify-content-between align-items-baseline">
<div class={`inv-grid-trend small ${isGain ? "up" : "down"}`}>
2026-03-25 08:42:17 -04:00
<span class="inv-grid-arrow">{isGain ? "▲" : "▼"}</span>
<span class="h6 my-0">${market.toFixed(2)}</span>
2026-03-25 08:42:17 -04:00
</div>
<div class={`inv-grid-delta small ${isGain ? "up" : "down"}`}>
{isGain ? "+" : "-"}${Math.abs(diff).toFixed(2)}</br>{isGain ? "+" : "-"}{Math.abs(pct).toFixed(2)}%
2026-03-25 08:42:17 -04:00
</div>
</div>
</article>
2026-03-25 08:42:17 -04:00
</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 {
text-align: right;
2026-03-25 08:42:17 -04:00
}
.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();
2026-03-25 08:42:17 -04:00
});
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>