1902 lines
65 KiB
HTML
1902 lines
65 KiB
HTML
<!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')">A–Z 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}% ${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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|