n64-tracker/static/index.html

1902 lines
65 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>N64 Collection Tracker</title>
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet">
<style>
/* ── Reset ─────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
/* ── N64 Color Palette ─────────────────────────────────────── */
/* Official N64 colors:
Logo Red #E4000F Logo Blue #0046AD
Logo Green #009B3A Logo Yellow #F5A800
Controller #2B2B2B Cartridge #C8102E */
:root {
--n64-red: #E4000F;
--n64-blue: #0046AD;
--n64-green: #009B3A;
--n64-yellow: #F5A800;
--n64-dark: #0D0D14;
--n64-darker: #07070E;
--n64-surface: #12121F;
--n64-card: #191929;
--n64-border: #252540;
--n64-text: #E8E8F0;
--n64-muted: #7070A0;
--n64-red-glow: rgba(228,0,15,0.25);
--n64-blue-glow:rgba(0,70,173,0.30);
}
/* ── Body ──────────────────────────────────────────────────── */
body {
background: var(--n64-darker);
color: var(--n64-text);
font-family: 'Press Start 2P', monospace;
min-height: 100vh;
overflow-x: hidden;
}
/* Scanlines */
body::after {
content: '';
position: fixed; inset: 0;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0,0,0,0.07) 2px,
rgba(0,0,0,0.07) 4px
);
pointer-events: none;
z-index: 9999;
}
/* ── Header ────────────────────────────────────────────────── */
.header {
background: linear-gradient(180deg, #0A0A18 0%, #070710 100%);
border-bottom: 3px solid var(--n64-red);
box-shadow: 0 3px 24px var(--n64-red-glow), 0 1px 0 rgba(255,255,255,0.04);
position: sticky;
top: 0;
z-index: 200;
padding: 12px 0 0;
}
.header-inner {
max-width: 1400px;
margin: 0 auto;
padding: 0 16px;
}
/* ── Logo row ──────────────────────────────────────────────── */
.header-top {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 10px;
}
.logo {
display: flex;
align-items: center;
gap: 10px;
}
/* N64 logo badge */
.logo-badge {
width: 40px; height: 40px;
border-radius: 8px;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
overflow: hidden;
box-shadow: 0 0 22px rgba(228,0,15,0.5), 0 0 8px rgba(0,70,173,0.4);
animation: badge-pulse 2.5s ease-in-out infinite;
flex-shrink: 0;
}
.logo-badge span {
display: block;
}
.lb-r { background: var(--n64-red); }
.lb-b { background: var(--n64-blue); }
.lb-y { background: var(--n64-yellow); }
.lb-g { background: var(--n64-green); }
@keyframes badge-pulse {
0%,100% { box-shadow: 0 0 22px rgba(228,0,15,0.5), 0 0 8px rgba(0,70,173,0.4); }
50% { box-shadow: 0 0 36px rgba(228,0,15,0.7), 0 0 18px rgba(0,70,173,0.6); }
}
.logo-text h1 {
font-size: 11px;
letter-spacing: 2px;
background: linear-gradient(90deg, var(--n64-red), var(--n64-yellow), var(--n64-blue));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
line-height: 1;
}
.logo-text .sub {
font-size: 6px;
color: var(--n64-muted);
letter-spacing: 2px;
margin-top: 4px;
text-transform: uppercase;
}
/* ── Stats pills ───────────────────────────────────────────── */
.stats-pills {
display: flex;
gap: 6px;
flex-wrap: wrap;
align-items: center;
}
.pill {
display: flex;
align-items: center;
gap: 7px;
padding: 5px 11px;
border-radius: 50px;
font-size: 7px;
border: 2px solid;
background: rgba(0,0,0,0.5);
white-space: nowrap;
transition: box-shadow 0.3s;
letter-spacing: 0.5px;
}
.pill .pill-num {
font-size: 12px;
line-height: 1;
}
.pill-owned {
border-color: var(--n64-green);
color: var(--n64-green);
box-shadow: 0 0 14px rgba(0,155,58,0.2);
}
.pill-unowned {
border-color: var(--n64-red);
color: var(--n64-red);
box-shadow: 0 0 14px rgba(228,0,15,0.2);
}
.pill-pct {
border-color: var(--n64-yellow);
color: var(--n64-yellow);
box-shadow: 0 0 14px rgba(245,168,0,0.2);
}
.pill-value {
border-color: #00C8FF;
color: #00C8FF;
box-shadow: 0 0 14px rgba(0,200,255,0.2);
}
.pill-needs-cost {
border-color: #FF8C00;
color: #FF8C00;
box-shadow: 0 0 14px rgba(255,140,0,0.2);
}
.refresh-btn {
font-family: 'Press Start 2P', monospace;
font-size: 6px;
padding: 5px 10px;
border-radius: 50px;
border: 2px solid var(--n64-muted);
color: var(--n64-muted);
background: transparent;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
letter-spacing: 0.5px;
}
.refresh-btn:hover { border-color: #00C8FF; color: #00C8FF; }
.refresh-btn.spinning { animation: spin 1s linear infinite; opacity: 0.6; cursor: wait; }
/* ── Progress bar ──────────────────────────────────────────── */
.progress-wrap {
padding: 6px 0 0;
position: relative;
}
.progress-label {
font-size: 6px;
color: var(--n64-muted);
letter-spacing: 1.5px;
margin-bottom: 4px;
display: flex;
justify-content: space-between;
}
.progress-track {
width: 100%;
height: 8px;
background: rgba(255,255,255,0.07);
border-radius: 5px;
overflow: hidden;
border: 1px solid var(--n64-border);
}
.progress-fill {
height: 100%;
border-radius: 5px;
background: linear-gradient(90deg, var(--n64-red), var(--n64-yellow), var(--n64-green));
transition: width 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);
position: relative;
}
.progress-fill::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(255,255,255,0.25) 0%, transparent 100%);
border-radius: 5px;
}
/* ── Search ────────────────────────────────────────────────── */
.search-wrap {
position: relative;
margin-top: 8px;
}
.search-icon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
font-size: 14px;
opacity: 0.4;
pointer-events: none;
}
.search-input {
width: 100%;
padding: 9px 14px 9px 38px;
background: rgba(0,0,0,0.6);
border: 2px solid var(--n64-border);
border-radius: 8px;
color: var(--n64-text);
font-family: 'Press Start 2P', monospace;
font-size: 9px;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.search-input:focus {
border-color: var(--n64-blue);
box-shadow: 0 0 18px var(--n64-blue-glow);
}
.search-input::placeholder { color: var(--n64-muted); }
/* ── Tabs ──────────────────────────────────────────────────── */
.tabs {
display: flex;
gap: 2px;
margin-top: 8px;
border-bottom: 2px solid var(--n64-border);
}
.tab {
padding: 7px 16px;
font-family: 'Press Start 2P', monospace;
font-size: 7px;
letter-spacing: 0.5px;
background: transparent;
color: var(--n64-muted);
border: 2px solid transparent;
border-bottom: none;
border-radius: 8px 8px 0 0;
cursor: pointer;
transition: all 0.2s;
position: relative;
bottom: -2px;
}
.tab:hover { color: var(--n64-text); background: rgba(255,255,255,0.04); }
.tab.active {
color: var(--n64-yellow);
border-color: var(--n64-border);
border-bottom-color: var(--n64-darker);
background: var(--n64-darker);
}
/* ── Main ──────────────────────────────────────────────────── */
.main {
max-width: 1400px;
margin: 0 auto;
padding: 20px 24px 40px;
}
.results-bar {
font-size: 7px;
color: var(--n64-muted);
letter-spacing: 2px;
margin-bottom: 14px;
padding: 0 2px;
}
/* ── Loading ───────────────────────────────────────────────── */
.loading {
display: none;
text-align: center;
padding: 80px 20px;
font-size: 9px;
color: var(--n64-muted);
}
.loading.show { display: block; }
.spinner {
display: inline-block;
width: 36px; height: 36px;
border: 3px solid var(--n64-border);
border-top-color: var(--n64-red);
border-right-color: var(--n64-blue);
border-radius: 50%;
animation: spin 0.7s linear infinite;
margin-bottom: 16px;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Game List (ALL GAMES tab) ─────────────────────────────── */
.game-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.list-row {
display: grid;
grid-template-columns: 28px 1fr auto auto;
align-items: center;
gap: 12px;
padding: 10px 14px;
border-radius: 6px;
border: 1px solid transparent;
background: var(--n64-card);
transition: all 0.15s;
cursor: default;
}
.list-row:hover {
border-color: var(--n64-border);
background: #1E1E30;
}
.list-row.is-owned {
border-left: 3px solid var(--n64-green);
padding-left: 11px;
}
.list-row.is-owned:hover {
border-color: var(--n64-green);
box-shadow: 0 0 12px rgba(0,155,58,0.12);
}
/* row num */
.row-num {
font-size: 7px;
color: var(--n64-muted);
text-align: right;
user-select: none;
}
/* row title */
.row-title {
font-size: 9px;
color: var(--n64-text);
line-height: 1.4;
cursor: pointer;
}
.row-title:hover { color: var(--n64-yellow); }
/* row meta tags */
.row-tags {
display: flex;
gap: 6px;
flex-wrap: nowrap;
justify-content: flex-end;
}
.tag {
font-size: 6px;
padding: 3px 7px;
border-radius: 3px;
white-space: nowrap;
letter-spacing: 0.5px;
}
.tag-pub { background: rgba(0,70,173,0.2); color: #6090FF; border: 1px solid rgba(0,70,173,0.3); }
.tag-date { background: rgba(245,168,0,0.15); color: var(--n64-yellow); border: 1px solid rgba(245,168,0,0.25); }
.tag-own { background: rgba(0,155,58,0.2); color: #40D070; border: 1px solid rgba(0,155,58,0.35); }
/* row button */
.row-btn {
font-family: 'Press Start 2P', monospace;
font-size: 7px;
padding: 7px 12px;
border-radius: 5px;
cursor: pointer;
border: 2px solid;
transition: all 0.15s;
white-space: nowrap;
letter-spacing: 0.5px;
}
.row-btn.add { border-color: var(--n64-green); color: var(--n64-green); background: transparent; }
.row-btn.add:hover { background: var(--n64-green); color: #000; box-shadow: 0 0 14px rgba(0,155,58,0.4); }
.row-btn.remove { border-color: var(--n64-red); color: var(--n64-red); background: transparent; }
.row-btn.remove:hover { background: var(--n64-red); color: #fff; box-shadow: 0 0 14px rgba(228,0,15,0.4); }
/* ── Collection Grid (MY COLLECTION tab) ──────────────────── */
.game-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 10px;
}
.game-card {
background: var(--n64-card);
border: 2px solid var(--n64-border);
border-radius: 10px;
overflow: hidden;
transition: all 0.2s;
cursor: pointer;
position: relative;
}
.game-card:hover {
border-color: var(--n64-yellow);
transform: translateY(-3px);
box-shadow: 0 8px 28px rgba(245,168,0,0.15);
}
/* Images are landscape 2100×1536 (≈4:3 ratio) */
.card-art {
width: 100%;
aspect-ratio: 2100 / 1536;
background: var(--n64-surface);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
position: relative;
}
.card-art img {
width: 100%; height: 100%;
object-fit: cover;
object-position: center;
display: block;
}
.card-art .no-art {
font-size: 36px;
opacity: 0.2;
}
.card-body { padding: 7px 8px 9px; }
.card-price {
font-size: 8px;
color: #00C8FF;
letter-spacing: 1px;
margin-bottom: 5px;
display: block;
}
.card-price.no-price { color: var(--n64-muted); font-size: 6px; }
.card-title {
font-size: 6px;
color: var(--n64-text);
line-height: 1.6;
margin-bottom: 5px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card-meta { font-size: 5px; color: var(--n64-muted); margin-bottom: 5px; }
.card-remove {
width: 100%;
font-family: 'Press Start 2P', monospace;
font-size: 6px;
padding: 5px;
border: 2px solid var(--n64-red);
color: var(--n64-red);
background: transparent;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s;
}
.card-remove:hover { background: var(--n64-red); color: #fff; }
/* Needs tab — unowned cards with red accent */
.needs-card {
border-color: rgba(228,0,15,0.35);
}
.needs-card:hover {
border-color: var(--n64-red);
box-shadow: 0 8px 28px rgba(228,0,15,0.18);
}
/* Collection card — fully checked, all buttons green */
.card-complete {
border-color: rgba(0,155,58,0.6) !important;
box-shadow: 0 0 16px rgba(0,155,58,0.25);
}
.card-complete:hover {
border-color: var(--n64-green) !important;
box-shadow: 0 0 24px rgba(0,155,58,0.4);
}
/* 3 prerequisite check buttons: CASE / CLEANED / BATTERY */
.needs-checks {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 4px;
margin-bottom: 5px;
}
.check-btn {
font-family: 'Press Start 2P', monospace;
font-size: 5px;
padding: 5px 1px;
border-radius: 3px;
border: 2px solid var(--n64-border);
color: var(--n64-muted);
background: transparent;
cursor: pointer;
transition: all 0.15s;
text-align: center;
letter-spacing: 0.3px;
line-height: 1.4;
}
.check-btn:hover {
border-color: var(--n64-yellow);
color: var(--n64-yellow);
}
.check-btn.checked {
border-color: var(--n64-green);
color: #000;
background: var(--n64-green);
box-shadow: 0 0 10px rgba(0,155,58,0.4);
}
/* ADD button — locked until all 3 checked */
.card-add {
width: 100%;
font-family: 'Press Start 2P', monospace;
font-size: 5.5px;
padding: 5px;
border: 2px solid var(--n64-border);
color: var(--n64-muted);
background: transparent;
border-radius: 4px;
cursor: not-allowed;
transition: all 0.2s;
opacity: 0.45;
}
.card-add.unlocked {
border-color: var(--n64-green);
color: var(--n64-green);
cursor: pointer;
opacity: 1;
}
.card-add.unlocked:hover {
background: var(--n64-green);
color: #000;
box-shadow: 0 0 14px rgba(0,155,58,0.4);
}
/* 4-button grid: 2×2 layout for battery games */
.needs-checks.has-battery {
grid-template-columns: 1fr 1fr;
}
/* ── Rarity badge ──────────────────────────────────────────── */
.rarity-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 5px;
padding: 3px 6px;
border-radius: 3px;
margin-bottom: 5px;
border: 1px solid;
letter-spacing: 0.5px;
white-space: nowrap;
}
.rarity-bar {
height: 3px;
border-radius: 2px;
margin-bottom: 5px;
width: 100%;
background: var(--n64-border);
overflow: hidden;
}
.rarity-bar-fill { height: 100%; border-radius: 2px; }
/* colour by rarity label */
.rar-common { border-color: #555580; color: #8080B0; }
.rar-uncommon { border-color: #3a7a3a; color: #5cc05c; }
.rar-sought { border-color: #7a7a00; color: #cccc00; }
.rar-collectible { border-color: #cc7700; color: #ffaa00; }
.rar-rare { border-color: var(--n64-red); color: #ff6060; }
.rar-ultra { border-color: #cc00cc; color: #ff60ff; }
.fill-common { background: #8080B0; }
.fill-uncommon { background: #5cc05c; }
.fill-sought { background: #cccc00; }
.fill-collectible { background: #ffaa00; }
.fill-rare { background: #ff6060; }
.fill-ultra { background: #ff60ff; }
/* ── Sort bar ──────────────────────────────────────────────── */
.sort-bar {
display: none;
align-items: center;
gap: 6px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.sort-bar.show { display: flex; }
.sort-label {
font-size: 6px;
color: var(--n64-muted);
letter-spacing: 2px;
white-space: nowrap;
}
.sort-btn {
font-family: 'Press Start 2P', monospace;
font-size: 6px;
padding: 5px 10px;
border-radius: 4px;
border: 2px solid var(--n64-border);
color: var(--n64-muted);
background: transparent;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
letter-spacing: 0.5px;
}
.sort-btn:hover { border-color: var(--n64-yellow); color: var(--n64-yellow); }
.sort-btn.active-asc { border-color: var(--n64-blue); color: var(--n64-blue); background: rgba(0,70,173,0.15); }
.sort-btn.active-desc { border-color: #00C8FF; color: #00C8FF; background: rgba(0,200,255,0.12); }
/* ── Empty state ───────────────────────────────────────────── */
.empty {
text-align: center;
padding: 80px 20px;
display: none;
}
.empty.show { display: block; }
.empty-icon { font-size: 56px; margin-bottom: 20px; opacity: 0.2; }
.empty p { font-size: 9px; color: var(--n64-muted); line-height: 2; }
/* ── Modal ─────────────────────────────────────────────────── */
.overlay {
display: none;
position: fixed; inset: 0;
background: rgba(0,0,0,0.88);
z-index: 1000;
align-items: center;
justify-content: center;
padding: 20px;
}
.overlay.show { display: flex; }
.modal {
background: var(--n64-card);
border: 2px solid var(--n64-border);
border-radius: 14px;
max-width: 560px;
width: 100%;
max-height: 88vh;
overflow-y: auto;
box-shadow: 0 0 60px rgba(0,70,173,0.35), 0 0 20px rgba(228,0,15,0.2);
animation: modal-in 0.2s ease-out;
}
@keyframes modal-in {
from { opacity: 0; transform: scale(0.95) translateY(10px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.modal-art {
width: 100%;
aspect-ratio: 2100 / 1536;
background: var(--n64-surface);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: 12px 12px 0 0;
position: relative;
}
.modal-art img { width: 100%; height: 100%; object-fit: cover; object-position: center; background: #000; }
.modal-art .no-art { font-size: 72px; opacity: 0.15; }
/* N64 color stripe across top of modal */
.modal-art::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 4px;
background: linear-gradient(90deg, var(--n64-red) 25%, var(--n64-blue) 25% 50%, var(--n64-yellow) 50% 75%, var(--n64-green) 75%);
z-index: 2;
}
.modal-body { padding: 22px; }
.modal-title {
font-size: 12px;
line-height: 1.7;
margin-bottom: 14px;
background: linear-gradient(90deg, var(--n64-red), var(--n64-blue));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.modal-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-bottom: 14px;
}
.modal-detail {
background: rgba(0,0,0,0.35);
padding: 9px 11px;
border-radius: 6px;
border: 1px solid var(--n64-border);
}
.modal-detail .lbl {
font-size: 6px;
color: var(--n64-muted);
letter-spacing: 1px;
text-transform: uppercase;
margin-bottom: 4px;
}
.modal-detail .val {
font-size: 8px;
color: var(--n64-text);
line-height: 1.5;
}
.modal-desc {
font-family: Arial, sans-serif;
font-size: 12px;
line-height: 1.8;
color: var(--n64-muted);
margin-bottom: 18px;
}
.modal-flags {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.flag {
font-size: 6px;
padding: 4px 8px;
border-radius: 4px;
border: 1px solid;
letter-spacing: 0.5px;
}
.flag-case { border-color: var(--n64-blue); color: var(--n64-blue); background: rgba(0,70,173,0.1); }
.flag-cleaned { border-color: var(--n64-green); color: var(--n64-green); background: rgba(0,155,58,0.1); }
.flag-battery { border-color: var(--n64-yellow); color: var(--n64-yellow); background: rgba(245,168,0,0.1); }
.flag-completed { border-color: #C080FF; color: #C080FF; background: rgba(192,128,255,0.1); }
.modal-actions { display: flex; gap: 10px; }
.modal-btn {
flex: 1;
font-family: 'Press Start 2P', monospace;
font-size: 8px;
padding: 11px;
border-radius: 7px;
cursor: pointer;
border: 2px solid;
transition: all 0.15s;
text-align: center;
}
.modal-btn.own { border-color: var(--n64-green); color: var(--n64-green); background: transparent; }
.modal-btn.own:hover { background: var(--n64-green); color: #000; }
.modal-btn.unown { border-color: var(--n64-red); color: var(--n64-red); background: transparent; }
.modal-btn.unown:hover { background: var(--n64-red); color: #fff; }
.modal-btn.close { border-color: var(--n64-border); color: var(--n64-muted); background: transparent; flex: 0; padding: 11px 18px; }
.modal-btn.close:hover { border-color: var(--n64-muted); color: var(--n64-text); }
/* ── Toast ─────────────────────────────────────────────────── */
.toasts {
position: fixed;
bottom: 20px; right: 20px;
z-index: 3000;
display: flex;
flex-direction: column;
gap: 8px;
}
.toast {
padding: 11px 18px;
border-radius: 7px;
font-size: 8px;
font-family: 'Press Start 2P', monospace;
letter-spacing: 0.5px;
animation: toast-in 0.25s ease-out;
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
max-width: 300px;
}
.toast.success { background: var(--n64-green); color: #000; }
.toast.error { background: var(--n64-red); color: #fff; }
.toast.info { background: var(--n64-blue); color: #fff; }
@keyframes toast-in {
from { opacity:0; transform: translateX(40px); }
to { opacity:1; transform: translateX(0); }
}
/* ── Responsive ────────────────────────────────────────────── */
@media (max-width: 700px) {
.header { padding: 8px 0 0; }
.header-inner { padding: 0 10px; }
.header-top { flex-direction: row; align-items: center; gap: 6px; margin-bottom: 6px; }
/* Logo */
.logo { gap: 7px; }
.logo-badge { width: 28px; height: 28px; border-radius: 5px; }
.logo-text h1 { font-size: 7px; letter-spacing: 1px; }
.logo-text .sub { font-size: 5px; letter-spacing: 1px; margin-top: 3px; }
/* Pills — hide labels, show numbers only in tiny pills */
.stats-pills { gap: 4px; }
.pill { padding: 3px 7px; font-size: 0; gap: 4px; border-width: 1px; }
.pill .pill-num { font-size: 9px; }
/* Show pill label text on mobile by keeping it but tiny */
.pill::before { font-size: 5px; }
/* Refresh / auth buttons */
.refresh-btn { font-size: 5px; padding: 4px 7px; }
/* Progress */
.progress-wrap { padding: 4px 0 0; }
.progress-label { font-size: 5px; letter-spacing: 1px; margin-bottom: 3px; }
.progress-track { height: 6px; }
/* Search */
.search-wrap { margin-top: 5px; }
.search-icon { font-size: 12px; left: 9px; }
.search-input { padding: 7px 10px 7px 30px; font-size: 7px; border-radius: 6px; }
/* Tabs */
.tabs { margin-top: 5px; }
.tab { padding: 5px 10px; font-size: 6px; letter-spacing: 0; }
/* Game grid / list */
.list-row { grid-template-columns: 22px 1fr auto; }
.row-tags { display: none; }
.modal-grid { grid-template-columns: 1fr; }
.game-grid { grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); }
}
/* Very small screens */
@media (max-width: 400px) {
.logo-text h1 { font-size: 6px; }
.logo-text .sub { display: none; }
.pill { padding: 3px 5px; }
.pill .pill-num { font-size: 8px; }
.tab { padding: 5px 8px; font-size: 5px; }
}
/* ── Auth / Settings modals ────────────────────────────────── */
.auth-overlay {
display: none;
position: fixed; inset: 0;
background: rgba(0,0,0,0.75);
z-index: 3000;
align-items: center;
justify-content: center;
}
.auth-overlay.show { display: flex; }
.auth-box {
background: var(--n64-surface);
border: 2px solid var(--n64-border);
border-radius: 10px;
padding: 32px 28px;
width: 340px;
max-width: 95vw;
display: flex;
flex-direction: column;
gap: 16px;
}
.auth-box h2 {
font-family: 'Press Start 2P', monospace;
font-size: 11px;
color: var(--n64-yellow);
text-align: center;
letter-spacing: 1px;
margin-bottom: 4px;
}
.auth-box label {
font-family: 'Press Start 2P', monospace;
font-size: 7px;
color: var(--n64-muted);
display: block;
margin-bottom: 5px;
}
.auth-box input[type=password], .auth-box input[type=text] {
width: 100%;
font-family: 'Press Start 2P', monospace;
font-size: 8px;
padding: 9px 10px;
background: var(--n64-dark);
border: 2px solid var(--n64-border);
border-radius: 4px;
color: var(--n64-text);
outline: none;
}
.auth-box input:focus { border-color: var(--n64-yellow); }
.auth-submit {
font-family: 'Press Start 2P', monospace;
font-size: 8px;
padding: 10px;
background: var(--n64-blue);
border: none;
border-radius: 4px;
color: #fff;
cursor: pointer;
letter-spacing: 0.5px;
transition: background 0.15s;
}
.auth-submit:hover { background: #0058d4; }
.auth-cancel {
font-family: 'Press Start 2P', monospace;
font-size: 7px;
background: transparent;
border: 1px solid var(--n64-border);
border-radius: 4px;
color: var(--n64-muted);
padding: 8px;
cursor: pointer;
}
.auth-cancel:hover { border-color: var(--n64-red); color: var(--n64-red); }
.auth-error {
font-family: 'Press Start 2P', monospace;
font-size: 7px;
color: var(--n64-red);
text-align: center;
display: none;
}
.auth-error.show { display: block; }
.auth-success {
font-family: 'Press Start 2P', monospace;
font-size: 7px;
color: var(--n64-green);
text-align: center;
display: none;
}
.auth-success.show { display: block; }
/* hide edit controls when not admin */
.admin-only { display: none !important; }
.admin-mode .admin-only { display: inline-flex !important; }
</style>
</head>
<body>
<!-- ═══════════════════ HEADER ═══════════════════ -->
<header class="header">
<div class="header-inner">
<div class="header-top">
<!-- Logo -->
<div class="logo">
<div class="logo-badge" aria-hidden="true">
<span class="lb-r"></span>
<span class="lb-b"></span>
<span class="lb-y"></span>
<span class="lb-g"></span>
</div>
<div class="logo-text">
<h1>N64 COLLECTION</h1>
<div class="sub">GAME TRACKER</div>
</div>
</div>
<!-- Pills -->
<div class="stats-pills">
<div class="pill pill-owned">
OWNED <span class="pill-num" id="s-owned"></span>
</div>
<div class="pill pill-unowned">
UNOWNED <span class="pill-num" id="s-unowned"></span>
</div>
<div class="pill pill-pct">
COMPLETE <span class="pill-num" id="s-pct"></span>
</div>
<div class="pill pill-value">
VALUE <span class="pill-num" id="s-value"></span>
</div>
<div class="pill pill-needs-cost">
TO BUY <span class="pill-num" id="s-needs-cost"></span>
</div>
<button class="refresh-btn admin-only" id="refresh-prices-btn" onclick="refreshPrices()" title="Refresh prices from PriceCharting">↺ PRICES</button>
<button class="refresh-btn admin-only" id="refresh-rarity-btn" onclick="refreshRarity()" title="Refresh rarity from rarityguide.com">↺ RARITY</button>
<button class="refresh-btn" id="auth-btn" onclick="openAuthModal()" title="Admin login / logout">🔒 LOGIN</button>
<button class="refresh-btn admin-only" id="settings-btn" onclick="openSettingsModal()" title="Settings">⚙ SETTINGS</button>
</div>
</div>
<!-- Progress bar -->
<div class="progress-wrap">
<div class="progress-label">
<span>COLLECTION PROGRESS</span>
<span id="prog-label">0 / 0</span>
</div>
<div class="progress-track">
<div class="progress-fill" id="progress-fill" style="width:0%"></div>
</div>
</div>
<!-- Search -->
<div class="search-wrap">
<span class="search-icon">🔍</span>
<input id="search" class="search-input"
type="text"
placeholder="SEARCH GAMES..."
autocomplete="off" spellcheck="false">
</div>
<!-- Tabs -->
<div class="tabs">
<button class="tab active" data-tab="all">ALL GAMES</button>
<button class="tab" data-tab="collection">MY COLLECTION</button>
<button class="tab" data-tab="needs">NEEDS</button>
</div>
</div>
</header>
<!-- ═══════════════════ MAIN ═══════════════════ -->
<main class="main">
<div class="results-bar" id="results-bar"></div>
<!-- Sort bar (collection + needs tabs only) -->
<div class="sort-bar" id="sort-bar">
<span class="sort-label">SORT:</span>
<button class="sort-btn" data-sort="title" onclick="setSort('title')">AZ TITLE</button>
<button class="sort-btn" data-sort="price-asc" onclick="setSort('price-asc')">$ LOW→HIGH</button>
<button class="sort-btn" data-sort="price-desc" onclick="setSort('price-desc')">$ HIGH→LOW</button>
<button class="sort-btn" data-sort="needed" onclick="setSort('needed')" id="sort-needed-btn">🔥 MOST NEEDED</button>
<button class="sort-btn" data-sort="case" onclick="setSort('case')">📦 CASE</button>
<button class="sort-btn" data-sort="cleaned" onclick="setSort('cleaned')">🧹 CLEANED</button>
<button class="sort-btn" data-sort="battery" onclick="setSort('battery')">🔋 BATTERY</button>
</div>
<div class="loading" id="loading">
<div class="spinner"></div>
<div>LOADING...</div>
</div>
<!-- All-games list view -->
<div class="game-list" id="game-list"></div>
<!-- Collection grid view -->
<div class="game-grid" id="game-grid" style="display:none;"></div>
<!-- Needs grid view -->
<div class="game-grid" id="needs-grid" style="display:none;"></div>
<!-- Empty state -->
<div class="empty" id="empty">
<div class="empty-icon">🎮</div>
<p>NO GAMES FOUND</p>
</div>
</main>
<!-- ═══════════════════ MODAL ═══════════════════ -->
<div class="overlay" id="overlay">
<div class="modal" id="modal">
<div class="modal-art" id="modal-art">
<span class="no-art">🎮</span>
</div>
<div class="modal-body">
<div class="modal-title" id="modal-title"></div>
<div class="modal-grid" id="modal-meta"></div>
<div class="modal-flags" id="modal-flags"></div>
<div class="modal-desc" id="modal-desc"></div>
<div class="modal-actions">
<button class="modal-btn own" id="modal-own-btn" onclick="modalToggleOwn()">+ ADD TO COLLECTION</button>
<button class="modal-btn close" onclick="closeModal()"></button>
</div>
</div>
</div>
</div>
<!-- Toasts -->
<div class="toasts" id="toasts"></div>
<!-- ── Login Modal ── -->
<div class="auth-overlay" id="auth-overlay">
<div class="auth-box" id="auth-box">
<h2>🔒 ADMIN LOGIN</h2>
<div>
<label>PASSWORD</label>
<input type="password" id="login-pw" placeholder="Enter password" onkeydown="if(event.key==='Enter')submitLogin()">
</div>
<div class="auth-error" id="login-error">Wrong password</div>
<button class="auth-submit" onclick="submitLogin()">UNLOCK</button>
<button class="auth-cancel" onclick="closeAuthModal()">CANCEL</button>
</div>
</div>
<!-- ── Settings Modal ── -->
<div class="auth-overlay" id="settings-overlay">
<div class="auth-box">
<h2>⚙ SETTINGS</h2>
<!-- Change password section -->
<div>
<label>CURRENT PASSWORD</label>
<input type="password" id="set-cur-pw" placeholder="Current password">
</div>
<div>
<label>NEW PASSWORD</label>
<input type="password" id="set-new-pw" placeholder="New password (min 4 chars)">
</div>
<div>
<label>CONFIRM NEW PASSWORD</label>
<input type="password" id="set-conf-pw" placeholder="Confirm new password" onkeydown="if(event.key==='Enter')submitChangePassword()">
</div>
<div class="auth-error" id="set-error"></div>
<div class="auth-success" id="set-success">Password changed!</div>
<button class="auth-submit" onclick="submitChangePassword()">CHANGE PASSWORD</button>
<button class="auth-cancel" onclick="closeSettingsModal()">CLOSE</button>
<button class="auth-cancel" style="border-color:var(--n64-red);color:var(--n64-red)" onclick="doLogout()">🔒 LOGOUT</button>
</div>
</div>
<script>
/* ═══════════════════════════════════════════════════════════
Config
═══════════════════════════════════════════════════════════ */
const BASE = '/n64'; // subpath — matches Flask BASE_PREFIX
/* ═══════════════════════════════════════════════════════════
State
═══════════════════════════════════════════════════════════ */
let allGames = [];
let currentTab = 'all';
let modalGame = null;
let searchTimer = null;
let currentSort = 'title';
let isAdmin = false;
/* ═══════════════════════════════════════════════════════════
Auth helpers
═══════════════════════════════════════════════════════════ */
function setAdminMode(authed) {
isAdmin = authed;
document.body.classList.toggle('admin-mode', authed);
const btn = document.getElementById('auth-btn');
if (btn) btn.textContent = authed ? '🔓 ADMIN' : '🔒 LOGIN';
}
async function checkAuthStatus() {
try {
const r = await fetch(BASE + '/api/auth/status');
const d = await r.json();
setAdminMode(d.authed);
} catch(e) {}
}
function openAuthModal() {
if (isAdmin) { openSettingsModal(); return; }
document.getElementById('login-pw').value = '';
document.getElementById('login-error').classList.remove('show');
document.getElementById('auth-overlay').classList.add('show');
setTimeout(() => document.getElementById('login-pw').focus(), 50);
}
function closeAuthModal() {
document.getElementById('auth-overlay').classList.remove('show');
}
async function submitLogin() {
const pw = document.getElementById('login-pw').value;
const errEl = document.getElementById('login-error');
errEl.classList.remove('show');
try {
const r = await fetch(BASE + '/api/auth/login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({password: pw})
});
const d = await r.json();
if (d.ok) {
setAdminMode(true);
closeAuthModal();
toast('Admin mode unlocked', 'success');
render();
} else {
errEl.textContent = d.error || 'Wrong password';
errEl.classList.add('show');
document.getElementById('login-pw').value = '';
document.getElementById('login-pw').focus();
}
} catch(e) {
errEl.textContent = 'Connection error';
errEl.classList.add('show');
}
}
function openSettingsModal() {
document.getElementById('set-cur-pw').value = '';
document.getElementById('set-new-pw').value = '';
document.getElementById('set-conf-pw').value = '';
document.getElementById('set-error').classList.remove('show');
document.getElementById('set-success').classList.remove('show');
document.getElementById('settings-overlay').classList.add('show');
}
function closeSettingsModal() {
document.getElementById('settings-overlay').classList.remove('show');
}
async function submitChangePassword() {
const cur = document.getElementById('set-cur-pw').value;
const nw = document.getElementById('set-new-pw').value;
const conf = document.getElementById('set-conf-pw').value;
const errEl = document.getElementById('set-error');
const okEl = document.getElementById('set-success');
errEl.classList.remove('show'); okEl.classList.remove('show');
if (nw !== conf) {
errEl.textContent = 'Passwords do not match';
errEl.classList.add('show'); return;
}
try {
const r = await fetch(BASE + '/api/auth/change-password', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({current: cur, new: nw})
});
const d = await r.json();
if (d.ok) {
okEl.classList.add('show');
document.getElementById('set-cur-pw').value = '';
document.getElementById('set-new-pw').value = '';
document.getElementById('set-conf-pw').value = '';
} else {
errEl.textContent = d.error || 'Error';
errEl.classList.add('show');
}
} catch(e) {
errEl.textContent = 'Connection error';
errEl.classList.add('show');
}
}
async function doLogout() {
await fetch(BASE + '/api/auth/logout', {method: 'POST'});
setAdminMode(false);
closeSettingsModal();
toast('Logged out', 'success');
render();
}
/* ═══════════════════════════════════════════════════════════
Boot
═══════════════════════════════════════════════════════════ */
document.addEventListener('DOMContentLoaded', () => {
checkAuthStatus();
fetchGames();
fetchStats();
document.getElementById('search').addEventListener('input', e => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => fetchGames(e.target.value), 220);
});
document.querySelectorAll('.tab').forEach(t => {
t.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(x => x.classList.remove('active'));
t.classList.add('active');
currentTab = t.dataset.tab;
render();
});
});
document.getElementById('overlay').addEventListener('click', e => {
if (e.target === e.currentTarget) closeModal();
});
document.getElementById('auth-overlay').addEventListener('click', e => {
if (e.target === e.currentTarget) closeAuthModal();
});
document.getElementById('settings-overlay').addEventListener('click', e => {
if (e.target === e.currentTarget) closeSettingsModal();
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape') { closeModal(); closeAuthModal(); closeSettingsModal(); }
});
});
/* ═══════════════════════════════════════════════════════════
Data
═══════════════════════════════════════════════════════════ */
async function fetchGames(q = '') {
showLoading(true);
try {
const url = q ? `${BASE}/api/games?q=${encodeURIComponent(q)}` : `${BASE}/api/games`;
const res = await fetch(url);
allGames = await res.json();
render();
} catch(e) {
toast('Failed to load games', 'error');
} finally {
showLoading(false);
}
}
async function fetchStats() {
try {
const s = await (await fetch(`${BASE}/api/stats`)).json();
document.getElementById('s-owned').textContent = s.owned;
document.getElementById('s-unowned').textContent = s.unowned;
const pct = s.total > 0 ? Math.round((s.owned / s.total) * 100) : 0;
document.getElementById('s-pct').textContent = pct + '%';
document.getElementById('prog-label').textContent = `${s.owned} / ${s.total}`;
document.getElementById('progress-fill').style.width = pct + '%';
const val = s.collection_value || 0;
document.getElementById('s-value').textContent = '$' + val.toLocaleString('en-US', {minimumFractionDigits: 2, maximumFractionDigits: 2});
const needsCost = s.needs_cost || 0;
document.getElementById('s-needs-cost').textContent = '$' + needsCost.toLocaleString('en-US', {minimumFractionDigits: 2, maximumFractionDigits: 2});
} catch(e) {}
}
async function refreshPrices() {
const btn = document.getElementById('refresh-prices-btn');
btn.classList.add('spinning');
btn.disabled = true;
try {
const res = await fetch(`${BASE}/api/prices/refresh`, { method: 'POST' });
const data = await res.json();
if (data.error) { toast('Price refresh failed', 'error'); return; }
toast(`Prices updated! ${data.updated} games matched.`, 'success');
await fetchGames();
await fetchStats();
} catch(e) {
toast('Price refresh failed', 'error');
} finally {
btn.classList.remove('spinning');
btn.disabled = false;
}
}
async function refreshRarity() {
const btn = document.getElementById('refresh-rarity-btn');
btn.classList.add('spinning');
btn.disabled = true;
try {
const res = await fetch(`${BASE}/api/rarity/refresh`, { method: 'POST' });
const data = await res.json();
if (data.error) { toast('Rarity refresh failed', 'error'); return; }
toast(`Rarity updated! ${data.updated} games matched.`, 'success');
await fetchGames();
render();
} catch(e) {
toast('Rarity refresh failed', 'error');
} finally {
btn.classList.remove('spinning');
btn.disabled = false;
}
}
/* ═══════════════════════════════════════════════════════════
Render
═══════════════════════════════════════════════════════════ */
function isFullyChecked(g) {
const battOk = BATTERY_GAMES.has(g.id) ? g.battery_replaced : true;
return g.has_case && g.cleaned && battOk;
}
function render() {
const list = document.getElementById('game-list');
const grid = document.getElementById('game-grid');
const needsGrid = document.getElementById('needs-grid');
const empty = document.getElementById('empty');
const sortBar = document.getElementById('sort-bar');
const neededBtn = document.getElementById('sort-needed-btn');
let games = allGames;
if (currentTab === 'collection') games = games.filter(g => g.owned);
else if (currentTab === 'needs') games = games.filter(g => !g.owned);
const showSort = currentTab !== 'all';
sortBar.classList.toggle('show', showSort);
if (neededBtn) neededBtn.style.display = currentTab === 'needs' ? '' : 'none';
if (showSort) games = applySort(games);
document.getElementById('results-bar').textContent =
`SHOWING ${games.length} GAME${games.length !== 1 ? 'S' : ''}`;
list.style.display = currentTab === 'all' ? 'flex' : 'none';
grid.style.display = currentTab === 'collection' && games.length ? 'grid' : 'none';
needsGrid.style.display = currentTab === 'needs' && games.length ? 'grid' : 'none';
empty.classList.toggle('show', games.length === 0);
if (currentTab === 'all') renderList(games, list);
else if (currentTab === 'collection') renderGrid(games, grid);
else if (currentTab === 'needs') renderNeeds(games, needsGrid);
}
function applySort(games) {
const arr = [...games];
switch (currentSort) {
case 'title':
arr.sort((a, b) => a.title.localeCompare(b.title));
break;
case 'price-asc':
arr.sort((a, b) => {
const pa = a.loose_price ?? Infinity;
const pb = b.loose_price ?? Infinity;
return pa - pb || a.title.localeCompare(b.title);
});
break;
case 'price-desc':
arr.sort((a, b) => {
const pa = a.loose_price ?? -Infinity;
const pb = b.loose_price ?? -Infinity;
return pb - pa || a.title.localeCompare(b.title);
});
break;
case 'needed':
// Most needed = highest rarity_pct first, tie-break by price desc
arr.sort((a, b) => {
const ra = a.rarity_pct ?? -1;
const rb = b.rarity_pct ?? -1;
if (rb !== ra) return rb - ra;
const pa = a.loose_price ?? -Infinity;
const pb = b.loose_price ?? -Infinity;
return pb - pa || a.title.localeCompare(b.title);
});
break;
case 'case':
// Unchecked first (needs attention), then checked, alpha tiebreak
arr.sort((a, b) => {
const ac = a.has_case ? 1 : 0;
const bc = b.has_case ? 1 : 0;
return ac - bc || a.title.localeCompare(b.title);
});
break;
case 'cleaned':
arr.sort((a, b) => {
const ac = a.cleaned ? 1 : 0;
const bc = b.cleaned ? 1 : 0;
return ac - bc || a.title.localeCompare(b.title);
});
break;
case 'battery':
// Only meaningful for battery games; non-battery games go to the bottom
arr.sort((a, b) => {
const aHasBatt = BATTERY_GAMES.has(a.id);
const bHasBatt = BATTERY_GAMES.has(b.id);
// Battery games first, non-battery games last
if (aHasBatt !== bHasBatt) return aHasBatt ? -1 : 1;
// Among battery games: unchecked first
if (aHasBatt && bHasBatt) {
const ar = a.battery_replaced ? 1 : 0;
const br = b.battery_replaced ? 1 : 0;
return ar - br || a.title.localeCompare(b.title);
}
return a.title.localeCompare(b.title);
});
break;
}
return arr;
}
function setSort(key) {
currentSort = key;
// Update button active states
document.querySelectorAll('.sort-btn').forEach(btn => {
btn.classList.remove('active-asc', 'active-desc');
if (btn.dataset.sort === key) {
btn.classList.add(key === 'price-asc' ? 'active-asc' : 'active-desc');
}
});
render();
}
/* ── List view (All Games) ── */
function renderList(games, container) {
container.innerHTML = games.map(g => {
const artUrl = g.cover_art_local
? `${BASE}/art/${g.cover_art_local.replace(/^coverart\//, '')}`
: (g.cover_art_remote || '');
return `
<div class="list-row${g.owned ? ' is-owned' : ''}" id="row-${g.id}">
<span class="row-num">${g.id}</span>
<span class="row-title" onclick="openModal(${g.id})">${esc(g.title)}</span>
<span class="row-tags">
${g.publisher ? `<span class="tag tag-pub">${esc(g.publisher)}</span>` : ''}
${g.release_date ? `<span class="tag tag-date">${esc(g.release_date)}</span>` : ''}
${g.owned ? `<span class="tag tag-own">✓ OWNED</span>` : ''}
</span>
${isAdmin ? `<button class="row-btn ${g.owned ? 'remove' : 'add'}"
onclick="toggleOwn(${g.id}, event)">
${g.owned ? ' REMOVE' : '+ ADD'}
</button>` : ''}
</div>`;
}).join('');
}
/* ── Grid view (My Collection tab) ── */
function renderGrid(games, container) {
container.innerHTML = games.map(g => {
const artPath = g.cover_art_local
? `${BASE}/art/${g.cover_art_local.replace(/^coverart\//, '')}`
: (g.cover_art_remote || '');
const artHtml = artPath
? `<img src="${esc(artPath)}" alt="${esc(g.title)}"
loading="lazy"
onerror="this.parentElement.innerHTML='<span class=no-art>🎮</span>'">`
: `<span class="no-art">🎮</span>`;
const priceHtml = g.loose_price != null
? `<span class="card-price">$${Number(g.loose_price).toFixed(2)}</span>`
: `<span class="card-price no-price">N/A</span>`;
const hasBattery = BATTERY_GAMES.has(g.id);
const complete = isFullyChecked(g);
const battBtn = hasBattery ? `
<button class="check-btn${g.battery_replaced ? ' checked' : ''}"
onclick="event.stopPropagation();ownedToggle(${g.id},'battery_replaced')"
title="Battery Replaced">🔋 BATT</button>` : '';
const adminControls = isAdmin ? `
<div class="needs-checks${hasBattery ? ' has-battery' : ''}">
<button class="check-btn${g.has_case ? ' checked' : ''}"
onclick="event.stopPropagation();ownedToggle(${g.id},'has_case')"
title="Has Case">📦 CASE</button>
<button class="check-btn${g.cleaned ? ' checked' : ''}"
onclick="event.stopPropagation();ownedToggle(${g.id},'cleaned')"
title="Cleaned">🧹 CLEAN</button>
${battBtn}
</div>
<button class="card-remove" onclick="event.stopPropagation();toggleOwn(${g.id}, event)">
REMOVE
</button>` : '';
return `
<div class="game-card${complete ? ' card-complete' : ''}" id="card-${g.id}" onclick="openModal(${g.id})">
<div class="card-art">${artHtml}</div>
<div class="card-body">
<div class="card-title">${esc(g.title)}</div>
<div class="card-meta">${esc(g.publisher || '')} · ${esc(g.release_date || '')}</div>
${priceHtml}
${adminControls}
</div>
</div>`;
}).join('');
}
/* Toggle a flag on an already-owned game and re-render into correct tab */
async function ownedToggle(id, field) {
const idx = allGames.findIndex(g => g.id === id);
if (idx < 0) return;
const newVal = !allGames[idx][field];
// Optimistic update
allGames[idx][field] = newVal;
try {
await fetch(`${BASE}/api/games/${id}/flags`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [field]: newVal })
});
} catch(e) {
// Revert on failure
allGames[idx][field] = !newVal;
toast('Update failed', 'error');
}
render();
}
/* ── Needs view (unowned games with 3-check flow) ── */
// Games that require a battery replacement check before adding to collection
const BATTERY_GAMES = new Set([2, 74, 91, 93, 126, 127, 181, 192, 208, 249, 250, 275, 284, 294]);
// Per-card check state: { gameId: { case: bool, cleaned: bool, battery: bool, own: bool } }
const needsChecks = {};
function rarityClass(label) {
if (!label) return 'rar-common';
const l = label.toLowerCase();
if (l.includes('ultra')) return 'rar-ultra';
if (l.includes('super rare') || l.includes('extremely collectible')) return 'rar-rare';
if (l.includes('highly collectible') || l.includes('very sought')) return 'rar-collectible';
if (l.includes('sought')) return 'rar-sought';
if (l.includes('uncommon')) return 'rar-uncommon';
return 'rar-common';
}
function rarityFillClass(label) {
return rarityClass(label).replace('rar-', 'fill-');
}
function renderNeeds(games, container) {
container.innerHTML = games.map(g => {
const hasBattery = BATTERY_GAMES.has(g.id);
if (!needsChecks[g.id]) needsChecks[g.id] = { case: false, cleaned: false, battery: false, own: false };
const artPath = g.cover_art_local
? `${BASE}/art/${g.cover_art_local.replace(/^coverart\//, '')}`
: (g.cover_art_remote || '');
const artHtml = artPath
? `<img src="${esc(artPath)}" alt="${esc(g.title)}"
loading="lazy"
onerror="this.parentElement.innerHTML='<span class=no-art>🎮</span>'">`
: `<span class="no-art">🎮</span>`;
const s = needsChecks[g.id];
// ADD unlocks when OWN is checked (+ BATT for battery games)
// CASE and CLEAN are optional — they determine which tab the game lands in
const canAdd = s.own && (!hasBattery || s.battery);
const rarHtml = g.rarity_pct != null ? `
<div class="rarity-badge ${rarityClass(g.rarity_label)}">
🎲 ${g.rarity_pct}% &nbsp;${esc(g.rarity_label || '')}
</div>
<div class="rarity-bar"><div class="rarity-bar-fill ${rarityFillClass(g.rarity_label)}" style="width:${g.rarity_pct}%"></div></div>` : '';
// Determine what tab the game will land in based on current checks
let destLabel = 'OWNED';
if (s.own && s.case && s.cleaned && (!hasBattery || s.battery)) destLabel = 'MY COLLECTION';
else if (s.own && s.cleaned) destLabel = 'CLEANED';
return `
<div class="game-card needs-card" id="needs-${g.id}" onclick="openModal(${g.id})">
<div class="card-art">${artHtml}</div>
<div class="card-body">
<div class="card-title">${esc(g.title)}</div>
<div class="card-meta">${esc(g.publisher || '')} · ${esc(g.release_date || '')}</div>
${g.loose_price != null ? `<span class="card-price">$${Number(g.loose_price).toFixed(2)}</span>` : `<span class="card-price no-price">N/A</span>`}
${rarHtml}
${isAdmin ? `
<div class="needs-checks${hasBattery ? ' has-battery' : ''}">
<button class="check-btn${s.case ? ' checked' : ''}"
onclick="event.stopPropagation();needsToggle(${g.id},'case')"
title="Has Case">📦 CASE</button>
<button class="check-btn${s.cleaned ? ' checked' : ''}"
onclick="event.stopPropagation();needsToggle(${g.id},'cleaned')"
title="Cleaned">🧹 CLEAN</button>
${hasBattery ? `<button class="check-btn${s.battery ? ' checked' : ''}"
onclick="event.stopPropagation();needsToggle(${g.id},'battery')"
title="Battery Replaced">🔋 BATT</button>` : ''}
<button class="check-btn${s.own ? ' checked' : ''}"
onclick="event.stopPropagation();needsToggle(${g.id},'own')"
title="I own this game">🎮 OWN</button>
</div>
<button class="card-add${canAdd ? ' unlocked' : ''}"
onclick="event.stopPropagation();needsAdd(${g.id})"
${canAdd ? '' : 'disabled'}>
${canAdd ? `✓ ADD TO ${destLabel}` : `🔒 CHECK OWN${hasBattery ? ' + BATT' : ''} FIRST`}
</button>` : ''}
</div>
</div>`;
}).join('');
}
function needsToggle(id, flag) {
if (!needsChecks[id]) needsChecks[id] = { case: false, cleaned: false, battery: false, own: false };
needsChecks[id][flag] = !needsChecks[id][flag];
const s = needsChecks[id];
const hasBattery = BATTERY_GAMES.has(id);
const canAdd = s.own && (!hasBattery || s.battery);
const card = document.getElementById(`needs-${id}`);
if (!card) return;
// Update check buttons
const flags = hasBattery ? ['case', 'cleaned', 'battery', 'own'] : ['case', 'cleaned', 'own'];
card.querySelectorAll('.check-btn').forEach((btn, i) => {
if (flags[i]) btn.classList.toggle('checked', needsChecks[id][flags[i]]);
});
// Determine destination tab label
let destLabel = 'OWNED';
if (s.own && s.case && s.cleaned && (!hasBattery || s.battery)) destLabel = 'MY COLLECTION';
else if (s.own && s.cleaned) destLabel = 'CLEANED';
// Update add button
const addBtn = card.querySelector('.card-add');
if (addBtn) {
addBtn.classList.toggle('unlocked', canAdd);
addBtn.disabled = !canAdd;
addBtn.textContent = canAdd ? `✓ ADD TO ${destLabel}` : `🔒 CHECK OWN${hasBattery ? ' + BATT' : ''} FIRST`;
}
}
async function needsAdd(id) {
const s = needsChecks[id];
const hasBattery = BATTERY_GAMES.has(id);
// OWN must be checked; battery required only for battery games
if (!s || !s.own || (hasBattery && !s.battery)) return;
try {
// Mark owned
const res = await fetch(`${BASE}/api/games/${id}/own`, { method: 'POST' });
const game = await res.json();
// Save only the flags that were actually checked
const flagPayload = {
has_case: s.case || false,
cleaned: s.cleaned || false,
battery_replaced: hasBattery ? (s.battery || false) : false
};
await fetch(`${BASE}/api/games/${id}/flags`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(flagPayload)
});
// Update local game record with actual flags
const idx = allGames.findIndex(g => g.id === id);
if (idx >= 0) {
allGames[idx] = { ...game, ...flagPayload };
}
// Clear check state for this game
delete needsChecks[id];
render();
fetchStats();
toast(`${game.title} added!`, 'success');
} catch(e) {
toast('Update failed', 'error');
}
}
/* ═══════════════════════════════════════════════════════════
Ownership toggle
═══════════════════════════════════════════════════════════ */
async function toggleOwn(id, event) {
if (event) event.stopPropagation();
// Optimistic UI: disable the button immediately
const row = document.getElementById(`row-${id}`);
if (row) {
const btn = row.querySelector('.row-btn');
if (btn) btn.disabled = true;
}
try {
const res = await fetch(`${BASE}/api/games/${id}/own`, { method: 'POST' });
const game = await res.json();
const idx = allGames.findIndex(g => g.id === id);
if (idx >= 0) allGames[idx] = game;
render();
fetchStats();
if (game.owned) {
toast(`${game.title} added!`, 'success');
} else {
toast(`${game.title} removed`, 'info');
}
if (modalGame && modalGame.id === id) {
modalGame = game;
updateModalBtn();
}
} catch(e) {
toast('Update failed', 'error');
}
}
/* ═══════════════════════════════════════════════════════════
Modal
═══════════════════════════════════════════════════════════ */
async function openModal(id) {
const game = allGames.find(g => g.id === id);
if (!game) return;
modalGame = game;
document.getElementById('overlay').classList.add('show');
populateModal(game);
// Fetch richer info if needed
if (!game.cover_art_local && !game.cover_art_remote && !game._fetchedInfo) {
try {
const res = await fetch(`${BASE}/api/games/${id}/info`);
const full = await res.json();
full._fetchedInfo = true;
const idx = allGames.findIndex(g => g.id === id);
if (idx >= 0) allGames[idx] = full;
modalGame = full;
populateModal(full);
} catch(e) {}
}
}
function populateModal(g) {
document.getElementById('modal-title').textContent = g.title;
// Art
const artEl = document.getElementById('modal-art');
const artPath = g.cover_art_local
? `${BASE}/art/${g.cover_art_local.replace(/^coverart\//, '')}`
: (g.cover_art_remote || '');
if (artPath) {
artEl.innerHTML = `<img src="${esc(artPath)}" alt="${esc(g.title)}"
onerror="this.parentElement.innerHTML='<span class=no-art>🎮</span>'">`;
} else {
artEl.innerHTML = `<span class="no-art">🎮</span>`;
}
// Meta grid
const metaEl = document.getElementById('modal-meta');
const rows = [
['PUBLISHER', g.publisher],
['RELEASED', g.release_date],
['DEVELOPER', g.developer],
['GENRE', g.genres],
['ESRB', g.rating],
['PLAYERS', g.players],
['CONDITION', g.condition],
['CART CODE', g.cart_code],
].filter(([,v]) => v);
metaEl.innerHTML = rows.map(([l,v]) => `
<div class="modal-detail">
<div class="lbl">${l}</div>
<div class="val">${esc(String(v))}</div>
</div>`).join('');
// Flags
const flagEl = document.getElementById('modal-flags');
const flags = [];
if (g.has_case) flags.push(`<span class="flag flag-case">✓ HAS CASE</span>`);
if (g.cleaned) flags.push(`<span class="flag flag-cleaned">✓ CLEANED</span>`);
if (g.battery_replaced) flags.push(`<span class="flag flag-battery">✓ BATTERY REPLACED</span>`);
if (g.completed) flags.push(`<span class="flag flag-completed">✓ COMPLETED</span>`);
flagEl.innerHTML = flags.join('');
// Description
document.getElementById('modal-desc').textContent = g.description || '';
updateModalBtn();
}
function updateModalBtn() {
const btn = document.getElementById('modal-own-btn');
if (!modalGame) return;
btn.style.display = isAdmin ? '' : 'none';
if (modalGame.owned) {
btn.textContent = ' REMOVE FROM COLLECTION';
btn.className = 'modal-btn unown';
} else {
btn.textContent = '+ ADD TO COLLECTION';
btn.className = 'modal-btn own';
}
}
function modalToggleOwn() {
if (modalGame) toggleOwn(modalGame.id);
}
function closeModal() {
document.getElementById('overlay').classList.remove('show');
modalGame = null;
}
/* ═══════════════════════════════════════════════════════════
Helpers
═══════════════════════════════════════════════════════════ */
function showLoading(on) {
document.getElementById('loading').classList.toggle('show', on);
}
function toast(msg, type = 'info') {
const el = document.createElement('div');
el.className = `toast ${type}`;
el.textContent = msg;
document.getElementById('toasts').appendChild(el);
setTimeout(() => {
el.style.transition = 'opacity 0.3s';
el.style.opacity = '0';
setTimeout(() => el.remove(), 300);
}, 2800);
}
function esc(s) {
if (!s) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
</script>
</body>
</html>