From 9975db20cb00d18c3b2bc9438da4f805c4ee7e84 Mon Sep 17 00:00:00 2001 From: Zach Harding Date: Wed, 25 Mar 2026 08:42:17 -0400 Subject: [PATCH] inventory dashboard setup --- src/assets/css/main.scss | 40 +- src/assets/js/main.js | 128 ++- src/components/CardGrid.astro | 74 +- src/components/InventoryTable.astro | 37 + src/pages/dashboard.astro | 1189 +++++++++++++++++++++++++++ src/pages/partials/card-modal.astro | 380 ++++++--- 6 files changed, 1657 insertions(+), 191 deletions(-) create mode 100644 src/components/InventoryTable.astro create mode 100644 src/pages/dashboard.astro diff --git a/src/assets/css/main.scss b/src/assets/css/main.scss index 32dbfa1..01177d1 100644 --- a/src/assets/css/main.scss +++ b/src/assets/css/main.scss @@ -278,6 +278,40 @@ $tiers: ( } } +// ── Inventory form condition buttons ────────────────────────────────────── +// Reuses $tiers map so colors stay in sync with nav tabs and price-row + +$cond-text: ( + nm: rgba(156, 204, 102, 1), + lp: rgba(211, 225, 86, 1), + mp: rgba(255, 238, 87, 1), + hp: rgba(255, 201, 41, 1), + dmg: rgba(255, 167, 36, 1), +); + +@each $name, $color in $tiers { + @if map-has-key($cond-text, $name) { + .btn-check:checked + .btn-cond-#{$name} { + background-color: $color; + border-color: $color; + color: rgba(0, 0, 0, 0.94); + } + + .btn-cond-#{$name} { + border-color: rgba($color, 0.4); + color: var(--bs-body-color); + background: transparent; + font-weight: 600; + transition: background-color 0.1s, border-color 0.1s; + } + + .btn-check:not(:checked) + .btn-cond-#{$name}:hover { + background-color: rgba($color, 0.67); + border-color: transparent; + } + } + } + /* -------------------------------------------------- Misc UI -------------------------------------------------- */ @@ -430,12 +464,6 @@ $tiers: ( color: #fff; } -#inventoryForm .btn-check:checked + .nav-link { - outline: 2px solid rgba(0, 0, 0, 0.4); - outline-offset: -2px; -} -#inventoryForm .nav-link { cursor: pointer; } - .fs-7 { font-size: 0.9rem !important; } diff --git a/src/assets/js/main.js b/src/assets/js/main.js index abc4f3a..316f997 100644 --- a/src/assets/js/main.js +++ b/src/assets/js/main.js @@ -2,51 +2,97 @@ import * as bootstrap from 'bootstrap'; window.bootstrap = bootstrap; import 'bootstrap/dist/js/bootstrap.bundle.min.js'; -// trap browser back and close the modal if open -const cardModal = document.getElementById('cardModal'); -const loadingMsg = cardModal.innerHTML; -// Push a new history state when the modal is shown -cardModal.addEventListener('shown.bs.modal', () => { - history.pushState({ modalOpen: true }, null, '#cardModal'); -}); -// Listen for the browser's back button (popstate event) -window.addEventListener('popstate', (e) => { - if (cardModal.classList.contains('show')) { - const modalInstance = bootstrap.Modal.getInstance(cardModal); - if (modalInstance) { - modalInstance.hide(); +document.addEventListener('DOMContentLoaded', () => { + + // Initialize all Bootstrap modals + document.querySelectorAll('.modal').forEach(modalEl => { + bootstrap.Modal.getOrCreateInstance(modalEl); + }); + + // Initialize tooltips + document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => { + if (!el._tooltipInstance) { + el._tooltipInstance = new bootstrap.Tooltip(el, { container: 'body' }); } - } -}); -// Trigger a back navigation when the modal is closed via its native controls (X, backdrop click) -cardModal.addEventListener('hide.bs.modal', () => { - cardModal.innerHTML = loadingMsg; - if (history.state && history.state.modalOpen) { - history.back(); - } -}); + }); + // ---------------- DASHBOARD LOGIC ---------------- + const toggleBtn = document.getElementById("toggleViewBtn"); + const gridView = document.getElementById("gridView"); + const tableView = document.getElementById("tableView"); + const searchInput = document.getElementById("inventorySearch"); + const tbody = document.getElementById("inventoryRows"); - import { Tooltip } from "bootstrap"; - - // Initialize all tooltips globally - const initTooltips = () => { - document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => { - if (!el._tooltipInstance) { - el._tooltipInstance = new Tooltip(el, { - container: 'body', // ensures tooltip is appended to body, important for modals - }); + if(toggleBtn && gridView && tableView && tbody) { + // TOGGLE GRID/TABLE + toggleBtn.addEventListener("click", () => { + if(gridView.style.display !== "none") { + gridView.style.display = "none"; + tableView.style.display = "block"; + toggleBtn.textContent = "Switch to Grid View"; + } else { + gridView.style.display = "block"; + tableView.style.display = "none"; + toggleBtn.textContent = "Switch to Table View"; } }); - }; - // Run on page load - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initTooltips); - } else { - initTooltips(); + // SEARCH FILTER + if(searchInput) { + searchInput.addEventListener("input", e => { + const term = e.target.value.toLowerCase(); + [...tbody.querySelectorAll("tr")].forEach(row => { + row.style.display = row.textContent.toLowerCase().includes(term) ? "" : "none"; + }); + }); + } + + // SORTING + document.querySelectorAll("th[data-key]").forEach(th => { + let sortAsc = true; + th.addEventListener("click", () => { + const key = th.dataset.key; + const indexMap = {name:0,set:1,condition:2,qty:3,price:4,market:5,gain:6}; + const idx = indexMap[key]; + const rows = [...tbody.querySelectorAll("tr")]; + + rows.sort((a,b) => { + let aText = a.children[idx].textContent.replace(/\$|,/g,'').toLowerCase(); + let bText = b.children[idx].textContent.replace(/\$|,/g,'').toLowerCase(); + if(!isNaN(aText) && !isNaN(bText)) return sortAsc ? aText-bText : bText-aText; + return sortAsc ? aText.localeCompare(bText) : bText.localeCompare(aText); + }); + + sortAsc = !sortAsc; + tbody.innerHTML=""; + rows.forEach(r => tbody.appendChild(r)); + }); + }); + + // INLINE EDITING + GAIN/LOSS UPDATE + tbody.addEventListener("input", e => { + const row = e.target.closest("tr"); + if(!row) return; + + const priceCell = row.querySelector(".editable-price"); + const qtyCell = row.querySelector(".editable-qty"); + const marketCell = row.children[5]; + const gainCell = row.querySelector(".gain"); + + if(e.target.classList.contains("editable-price")) { + e.target.textContent = e.target.textContent.replace(/[^\d.]/g,""); + } + if(e.target.classList.contains("editable-qty")) { + e.target.textContent = e.target.textContent.replace(/\D/g,""); + } + + const price = parseFloat(priceCell.textContent) || 0; + const qty = parseInt(qtyCell.textContent) || 0; + const market = parseFloat(marketCell.textContent) || 0; + const gain = market - price; + + gainCell.textContent = (gain>=0 ? "+" : "-") + Math.abs(gain); + gainCell.className = gain>=0 ? "gain text-success" : "gain text-danger"; + }); } - - // Optional: observe DOM changes for dynamically added tooltips (e.g., modals loaded later) - const observer = new MutationObserver(() => initTooltips()); - observer.observe(document.body, { childList: true, subtree: true }); \ No newline at end of file +}); \ No newline at end of file diff --git a/src/components/CardGrid.astro b/src/components/CardGrid.astro index 209439c..05d33f8 100644 --- a/src/components/CardGrid.astro +++ b/src/components/CardGrid.astro @@ -44,15 +44,54 @@ import BackToTop from "./BackToTop.astro" - - \ No newline at end of file diff --git a/src/components/InventoryTable.astro b/src/components/InventoryTable.astro new file mode 100644 index 0000000..c8b9a1e --- /dev/null +++ b/src/components/InventoryTable.astro @@ -0,0 +1,37 @@ +--- +const mockInventory = [ + { name: "Charizard", set: "Base Set", condition: "NM", qty: 2, price: 350, market: 400, gain: 50 }, + { name: "Pikachu", set: "Shining Legends", condition: "LP", qty: 5, price: 15, market: 20, gain: 5 }, +]; +--- + +
+ + + + + + + + + + + + + + {mockInventory.map(card => ( + + + + + + + + + + ))} + +
CardSetConditionQtyPriceMarketGain/Loss
{card.name}{card.set}{card.condition}{card.qty}${card.price}${card.market}= 0 ? "text-success" : "text-danger"}> + {card.gain >= 0 ? "+" : "-"}${Math.abs(card.gain)} +
+
\ No newline at end of file diff --git a/src/pages/dashboard.astro b/src/pages/dashboard.astro new file mode 100644 index 0000000..ff26a86 --- /dev/null +++ b/src/pages/dashboard.astro @@ -0,0 +1,1189 @@ +--- +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); +--- + + + + + + +
+ + +
+
+
+ + +
+ +
+ + + + + +
+ + +
+
+ +
+
+ {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 ( +
+
+
+
+ {card.productName} + + + +
+
+ +
+
+
{card.productName}
+
+
{card.setName}
+
+
+ +
+
+ {isGain ? "▲" : "▼"} + ${market.toFixed(2)} +
+
+ {isGain ? "+" : "-"}${Math.abs(diff).toFixed(2)} ({isGain ? "+" : "-"}{Math.abs(pct).toFixed(2)}%) +
+
+
+
+ + + +
+
+
+
+ +
+
+ ); + })} +
+ + +
+
+
+ + + + + + + + + +