Initial commit
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 36 36'><rect x='4' y='6' width='28' height='22' rx='2' fill='%2333FF33' opacity='.15'/><rect x='6' y='8' width='24' height='16' fill='%230A0A0A'/><rect x='8' y='10' width='20' height='10' fill='%2333FF33' opacity='.3'/><rect x='10' y='12' width='6' height='4' fill='%2333FF33' opacity='.6'/><rect x='18' y='12' width='6' height='4' fill='%2333FF33' opacity='.4'/></svg>" />
|
||||
<title>DOStalgia</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3087
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "dostalgia-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"@testing-library/svelte": "^5.3.1",
|
||||
"jsdom": "^29.1.1",
|
||||
"svelte": "^5.0.0",
|
||||
"vite": "^5.0.0",
|
||||
"vitest": "^4.1.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<script>
|
||||
import "./app.css";
|
||||
import Header from "./lib/Header.svelte";
|
||||
import Library from "./pages/Library.svelte";
|
||||
import GameDetail from "./pages/GameDetail.svelte";
|
||||
import Play from "./pages/Play.svelte";
|
||||
|
||||
let route = $state("/");
|
||||
let gameId = $state(null);
|
||||
let filterGenre = $state("");
|
||||
let filterYear = $state("");
|
||||
let uploadTriggered = $state(0);
|
||||
|
||||
function parseHash() {
|
||||
const hash = window.location.hash.slice(1) || "/";
|
||||
const [pathPart, qs] = hash.split("?");
|
||||
const parts = pathPart.split("/").filter(Boolean);
|
||||
if (parts.length === 0 || (parts.length === 1 && parts[0] === "")) {
|
||||
route = "/";
|
||||
gameId = null;
|
||||
// Parse filter query params
|
||||
const params = new URLSearchParams(qs || "");
|
||||
filterGenre = params.get("genre") || "";
|
||||
filterYear = params.get("year") || "";
|
||||
} else if (parts[0] === "game" && parts[1]) {
|
||||
route = "/game";
|
||||
gameId = parts[1];
|
||||
} else if (parts[0] === "play" && parts[1]) {
|
||||
route = "/play";
|
||||
gameId = parts[1];
|
||||
} else {
|
||||
route = "/";
|
||||
gameId = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for hash changes and page loads
|
||||
$effect(() => {
|
||||
parseHash();
|
||||
const handler = () => parseHash();
|
||||
window.addEventListener("hashchange", handler);
|
||||
return () => window.removeEventListener("hashchange", handler);
|
||||
});
|
||||
</script>
|
||||
|
||||
<Header onUploadClick={() => uploadTriggered++} />
|
||||
|
||||
<main class="container">
|
||||
{#if route === "/"}
|
||||
<Library genreFilter={filterGenre} yearFilter={filterYear} {uploadTriggered} />
|
||||
{:else if route === "/game" && gameId}
|
||||
<GameDetail id={gameId} />
|
||||
{:else if route === "/play" && gameId}
|
||||
<Play id={gameId} />
|
||||
{:else}
|
||||
<div class="empty">
|
||||
<h2>Page not found</h2>
|
||||
<a href="#/">Back to Library</a>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 80px 20px;
|
||||
}
|
||||
.empty h2 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,250 @@
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
DOSTALGIA — Phosphor Dark Theme
|
||||
Inspired by classic green-screen CRT monitors (IBM 5151)
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
:root {
|
||||
/* Phosphor greens — from brightest glow to darkest trace */
|
||||
--phosphor: #33ff33;
|
||||
--phosphor-glow: #66ff66;
|
||||
--phosphor-dim: #22aa22;
|
||||
--phosphor-dark: #116611;
|
||||
--phosphor-burn: #0a330a;
|
||||
|
||||
/* Backgrounds */
|
||||
--bg: #0a0a0a;
|
||||
--bg-elevated: #111111;
|
||||
--surface: #1a1a1a;
|
||||
--surface-hover: #222222;
|
||||
--surface-active: #2a2a2a;
|
||||
|
||||
/* Borders */
|
||||
--border: #2a2a2a;
|
||||
--border-glow: #33ff3340;
|
||||
|
||||
/* Text */
|
||||
--text: #cccccc;
|
||||
--text-bright: #e8e8e8;
|
||||
--text-dim: #777777;
|
||||
|
||||
/* Sizing */
|
||||
--max-width: 1200px;
|
||||
--header-height: 64px;
|
||||
--radius: 8px;
|
||||
--radius-sm: 4px;
|
||||
|
||||
/* Typography */
|
||||
--font-mono: "Press Start 2P", monospace;
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
RESET & BASE
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 16px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
line-height: 1.6;
|
||||
overflow-x: hidden;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
html {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--phosphor);
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover {
|
||||
color: var(--phosphor-glow);
|
||||
text-shadow: 0 0 8px var(--phosphor-glow);
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
TYPOGRAPHY
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
h1, h2, h3, h4 {
|
||||
color: var(--text-bright);
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
h1 { font-size: 2rem; }
|
||||
h2 { font-size: 1.5rem; }
|
||||
h3 { font-size: 1.2rem; }
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
BUTTONS
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
border: 1px solid var(--phosphor-dim);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--phosphor);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.btn:hover {
|
||||
background: var(--phosphor-burn);
|
||||
border-color: var(--phosphor);
|
||||
box-shadow: 0 0 12px var(--phosphor-dark);
|
||||
}
|
||||
.btn:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--phosphor-dark);
|
||||
border-color: var(--phosphor);
|
||||
color: var(--phosphor-glow);
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: var(--phosphor-dim);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
border-color: #cc3333;
|
||||
color: #cc3333;
|
||||
}
|
||||
.btn-danger:hover {
|
||||
background: #331111;
|
||||
border-color: #ff4444;
|
||||
color: #ff4444;
|
||||
box-shadow: 0 0 12px #331111;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
INPUTS
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
padding: 10px 14px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.9rem;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
input:focus,
|
||||
textarea:focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: var(--phosphor-dim);
|
||||
box-shadow: 0 0 0 3px var(--border-glow);
|
||||
}
|
||||
input::placeholder {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
LAYOUT UTILITIES
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
.container {
|
||||
max-width: var(--max-width);
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.container { padding: 0 12px; }
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
RESPONSIVE TOUCH ADJUSTMENTS
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
@media (max-width: 640px) {
|
||||
h1 { font-size: 1.5rem; }
|
||||
h2 { font-size: 1.25rem; }
|
||||
.btn { padding: 8px 14px; font-size: 0.85rem; min-height: 40px; }
|
||||
input, textarea, select { font-size: 16px; } /* prevents iOS zoom */
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
SCROLLBAR
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--surface-hover);
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--phosphor-dark);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
ANIMATIONS
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
@keyframes scanline {
|
||||
0% { transform: translateY(0); }
|
||||
100% { transform: translateY(4px); }
|
||||
}
|
||||
|
||||
@keyframes flicker {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.97; }
|
||||
}
|
||||
|
||||
@keyframes phosphor-pulse {
|
||||
0%, 100% { text-shadow: 0 0 4px var(--phosphor-dim); }
|
||||
50% { text-shadow: 0 0 12px var(--phosphor), 0 0 24px var(--phosphor-dark); }
|
||||
}
|
||||
|
||||
/* Subtle CRT effect on hover cards */
|
||||
.crt-hover {
|
||||
position: relative;
|
||||
}
|
||||
.crt-hover::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 2px,
|
||||
rgba(51, 255, 51, 0.015) 2px,
|
||||
rgba(51, 255, 51, 0.015) 4px
|
||||
);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
.crt-hover:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
<script>
|
||||
import { push } from "../lib/router.js";
|
||||
|
||||
let { game } = $props();
|
||||
|
||||
function navigate() {
|
||||
push(`/game/${game.id}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<button class="card crt-hover" onclick={navigate}>
|
||||
<div class="cover">
|
||||
{#if game.has_cover}
|
||||
<img src={`/games/${game.id}/cover.jpg`} alt={game.title} loading="lazy" />
|
||||
{:else}
|
||||
<div class="cover-placeholder">
|
||||
<span class="cover-icon">💾</span>
|
||||
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="info">
|
||||
<h3>{game.title}</h3>
|
||||
{#if game.year}
|
||||
<span class="year">{game.year}</span>
|
||||
{/if}
|
||||
{#if game.genre}
|
||||
<span class="genre">{game.genre}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if game.platform === 'windows'}
|
||||
<div class="badge badge-windows">🪟 Win</div>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
.card:hover {
|
||||
border-color: var(--phosphor-dim);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 20px rgba(51, 255, 51, 0.1);
|
||||
}
|
||||
.cover {
|
||||
aspect-ratio: 3/4;
|
||||
background: var(--bg-elevated);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.cover-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.cover-icon {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
.info {
|
||||
padding: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
.info h3 {
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 4px;
|
||||
color: var(--text-bright);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.info { padding: 8px; }
|
||||
.info h3 { font-size: 0.8rem; }
|
||||
.year, .genre { font-size: 0.7rem; }
|
||||
.badge { font-size: 0.6rem; padding: 1px 6px; top: 4px; right: 4px; }
|
||||
}
|
||||
.year, .genre {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-dim);
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background: var(--phosphor-dark);
|
||||
color: var(--phosphor);
|
||||
font-size: 0.7rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.badge-windows {
|
||||
background: #1a3a5c;
|
||||
color: #6ab0ff;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,72 @@
|
||||
<script>
|
||||
let { onUploadClick = () => {} } = $props();
|
||||
</script>
|
||||
|
||||
<header class="header">
|
||||
<div class="header-inner">
|
||||
<a href="#/" class="logo">
|
||||
<svg class="logo-icon" width="36" height="36" viewBox="0 0 36 36" fill="none">
|
||||
<rect x="4" y="6" width="28" height="22" rx="2" fill="#33FF33" opacity="0.15"/>
|
||||
<rect x="6" y="8" width="24" height="16" fill="#0A0A0A"/>
|
||||
<rect x="8" y="10" width="20" height="10" fill="#33FF33" opacity="0.3"/>
|
||||
<rect x="10" y="12" width="6" height="4" fill="#33FF33" opacity="0.6"/>
|
||||
<rect x="18" y="12" width="6" height="4" fill="#33FF33" opacity="0.4"/>
|
||||
<rect x="10" y="24" width="16" height="3" fill="#33FF33" opacity="0.2"/>
|
||||
<rect x="12" y="29" width="12" height="2" fill="#33FF33" opacity="0.1"/>
|
||||
</svg>
|
||||
<span class="logo-text">DOSTALGIA</span>
|
||||
</a>
|
||||
<nav>
|
||||
<button class="btn" onclick={onUploadClick}>+ Upload</button>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.header {
|
||||
height: var(--header-height);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-elevated);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
.header-inner {
|
||||
max-width: var(--max-width);
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.header-inner { padding: 0 12px; }
|
||||
}
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.logo-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.logo-text {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: var(--phosphor);
|
||||
letter-spacing: 0.15em;
|
||||
animation: phosphor-pulse 3s ease-in-out infinite;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.logo-text { font-size: 1rem; letter-spacing: 0.1em; }
|
||||
.logo-icon { width: 28px; height: 28px; }
|
||||
}
|
||||
nav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/svelte";
|
||||
import GameCard from "../GameCard.svelte";
|
||||
|
||||
function makeGame(overrides = {}) {
|
||||
return {
|
||||
id: "test-1",
|
||||
title: "Test Game",
|
||||
year: 1995,
|
||||
genre: "Adventure",
|
||||
platform: "dos",
|
||||
has_cover: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("GameCard", () => {
|
||||
it("renders the game title", () => {
|
||||
render(GameCard, { props: { game: makeGame() } });
|
||||
expect(screen.getByText("Test Game")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the year when provided", () => {
|
||||
render(GameCard, { props: { game: makeGame({ year: 1997 }) } });
|
||||
expect(screen.getByText("1997")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not render year when absent", () => {
|
||||
render(GameCard, { props: { game: makeGame({ year: null }) } });
|
||||
expect(screen.queryByText("1995")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the genre when provided", () => {
|
||||
render(GameCard, { props: { game: makeGame({ genre: "RPG" }) } });
|
||||
expect(screen.getByText("RPG")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows a cover image when has_cover is true", () => {
|
||||
const game = makeGame({ has_cover: true });
|
||||
render(GameCard, { props: { game } });
|
||||
const img = screen.getByRole("img");
|
||||
expect(img).toBeTruthy();
|
||||
expect(img.getAttribute("src")).toBe(`/games/${game.id}/cover.jpg`);
|
||||
expect(img.getAttribute("alt")).toBe(game.title);
|
||||
});
|
||||
|
||||
it("shows a placeholder when has_cover is false", () => {
|
||||
render(GameCard, { props: { game: makeGame({ has_cover: false }) } });
|
||||
expect(screen.getByText("💾")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows Windows badge for windows platform", () => {
|
||||
render(GameCard, {
|
||||
props: { game: makeGame({ platform: "windows" }) },
|
||||
});
|
||||
expect(screen.getByText(/🪟/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not show Windows badge for DOS platform", () => {
|
||||
render(GameCard, { props: { game: makeGame({ platform: "dos" }) } });
|
||||
expect(screen.queryByText(/🪟/)).toBeNull();
|
||||
});
|
||||
|
||||
it("navigates on click", async () => {
|
||||
window.location.hash = "";
|
||||
render(GameCard, { props: { game: makeGame() } });
|
||||
const btn = screen.getByRole("button");
|
||||
btn.click();
|
||||
expect(window.location.hash).toBe("#/game/test-1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { artworkUrl, bundleUrl, setupBundleUrl } from "../api.js";
|
||||
|
||||
describe("artworkUrl", () => {
|
||||
it("returns null for null input", () => {
|
||||
expect(artworkUrl(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for undefined input", () => {
|
||||
expect(artworkUrl(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("passes through absolute HTTP URLs", () => {
|
||||
expect(artworkUrl("https://example.com/cover.jpg")).toBe(
|
||||
"https://example.com/cover.jpg"
|
||||
);
|
||||
});
|
||||
|
||||
it("proxies relative paths through /artwork/", () => {
|
||||
expect(artworkUrl("images/game.png")).toBe("/artwork/images/game.png");
|
||||
});
|
||||
|
||||
it("preserves nested relative paths", () => {
|
||||
expect(artworkUrl("some/deep/path/file.webp")).toBe(
|
||||
"/artwork/some/deep/path/file.webp"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bundleUrl", () => {
|
||||
it("returns null for null game", () => {
|
||||
expect(bundleUrl(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for undefined game", () => {
|
||||
expect(bundleUrl(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("builds URL from bundle_file", () => {
|
||||
expect(bundleUrl({ bundle_file: "mygame.zip" })).toBe("/games/mygame.zip");
|
||||
});
|
||||
|
||||
it("preserves subdirectory bundle paths", () => {
|
||||
expect(bundleUrl({ bundle_file: "subdir/game.zip" })).toBe(
|
||||
"/games/subdir/game.zip"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setupBundleUrl", () => {
|
||||
it("returns null for null game", () => {
|
||||
expect(setupBundleUrl(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for undefined game", () => {
|
||||
expect(setupBundleUrl(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("builds API setup URL from game id", () => {
|
||||
expect(setupBundleUrl({ id: "game-1" })).toBe(
|
||||
"/api/games/game-1/setup-bundle"
|
||||
);
|
||||
});
|
||||
|
||||
it("handles games with numeric id", () => {
|
||||
expect(setupBundleUrl({ id: 42 })).toBe("/api/games/42/setup-bundle");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { push, replace } from "../router.js";
|
||||
|
||||
describe("router", () => {
|
||||
describe("push", () => {
|
||||
it("sets window.location.hash with # prefix", () => {
|
||||
window.location.hash = "";
|
||||
push("/game/123");
|
||||
expect(window.location.hash).toBe("#/game/123");
|
||||
});
|
||||
|
||||
it("handles root path", () => {
|
||||
window.location.hash = "";
|
||||
push("/");
|
||||
expect(window.location.hash).toBe("#/");
|
||||
});
|
||||
|
||||
it("replaces hash on subsequent calls", () => {
|
||||
window.location.hash = "";
|
||||
push("/first");
|
||||
push("/second");
|
||||
expect(window.location.hash).toBe("#/second");
|
||||
});
|
||||
});
|
||||
|
||||
describe("replace", () => {
|
||||
it("sets hash via replace (no back-navigation allowed)", () => {
|
||||
window.location.hash = "#/old";
|
||||
replace("/new");
|
||||
expect(window.location.hash).toBe("#/new");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
/** API client for DOStalgia backend. */
|
||||
|
||||
const BASE = import.meta.env.PROD ? "" : ""; // Proxy handles it in dev
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
headers: { "Content-Type": "application/json", ...options.headers },
|
||||
...options,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
throw new Error(err.detail || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Fetch game list */
|
||||
export async function listGames(params = {}) {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.search) qs.set("search", params.search);
|
||||
if (params.genre) qs.set("genre", params.genre);
|
||||
if (params.limit) qs.set("limit", params.limit);
|
||||
if (params.offset) qs.set("offset", params.offset);
|
||||
return request(`/api/games?${qs}`);
|
||||
}
|
||||
|
||||
/** Fetch single game */
|
||||
export async function getGame(id) {
|
||||
return request(`/api/games/${id}`);
|
||||
}
|
||||
|
||||
/** Update game metadata */
|
||||
export async function updateGame(id, data) {
|
||||
return request(`/api/games/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
/** Delete a game */
|
||||
export async function deleteGame(id) {
|
||||
return request(`/api/games/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
/** Upload a game ZIP */
|
||||
export async function uploadGame(file, title, igdbId) {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
if (title) form.append("title", title);
|
||||
if (igdbId) form.append("igdb_id", String(igdbId));
|
||||
|
||||
const res = await fetch(`${BASE}/api/upload`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
throw new Error(err.detail || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Search IGDB for a game title (requires TWITCH_CLIENT_ID/SECRET set) */
|
||||
export async function searchIGDB(query) {
|
||||
const res = await fetch(`${BASE}/api/igdb/search?q=${encodeURIComponent(query)}`);
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(err.error || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Apply IGDB metadata + cover to a game (auto or by igdb_id) */
|
||||
export async function scrapeIGDB(gameId, igdbId) {
|
||||
const body = igdbId ? { igdb_id: igdbId } : {};
|
||||
const res = await fetch(`${BASE}/api/igdb/scrape/${gameId}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(err.error || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Download a game's ZIP bundle */
|
||||
export function downloadGame(id) {
|
||||
window.open(`${BASE}/api/games/${id}/download`, '_blank');
|
||||
}
|
||||
|
||||
/** Check IGDB status */
|
||||
export async function igdbStatus() {
|
||||
const res = await fetch(`${BASE}/api/igdb/status`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Get artwork URL for a path — returns proxy URL or placeholder */
|
||||
export function artworkUrl(path) {
|
||||
if (!path) return null;
|
||||
if (path.startsWith("http")) return path;
|
||||
return `/artwork/${path}`;
|
||||
}
|
||||
|
||||
/** Get game bundle URL for normal play */
|
||||
export function bundleUrl(game) {
|
||||
if (!game) return null;
|
||||
// Use the bundle_file path from the backend (handles both old flat layout
|
||||
// and new in-directory layout transparently)
|
||||
return `/games/${game.bundle_file}`;
|
||||
}
|
||||
|
||||
/** Get game bundle URL for setup mode (runs SETUP.EXE instead) */
|
||||
export function setupBundleUrl(game) {
|
||||
if (!game) return null;
|
||||
return `/api/games/${game.id}/setup-bundle`;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/** Simple hash-based router for SPA. */
|
||||
|
||||
export function push(path) {
|
||||
window.location.hash = "#" + path;
|
||||
}
|
||||
|
||||
export function replace(path) {
|
||||
window.location.replace("#" + path);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import App from "./App.svelte";
|
||||
import { mount } from "svelte";
|
||||
|
||||
const app = mount(App, {
|
||||
target: document.getElementById("app"),
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,714 @@
|
||||
<script>
|
||||
import { getGame, deleteGame, updateGame, searchIGDB, scrapeIGDB, downloadGame, bundleUrl, setupBundleUrl, artworkUrl } from "../lib/api.js";
|
||||
import { push } from "../lib/router.js";
|
||||
|
||||
let { id } = $props();
|
||||
|
||||
let game = $state(null);
|
||||
let loading = $state(true);
|
||||
let editing = $state(false);
|
||||
let saving = $state(false);
|
||||
let error = $state("");
|
||||
|
||||
let editTitle = $state("");
|
||||
let editYear = $state(0);
|
||||
let editGenre = $state("");
|
||||
let editDeveloper = $state("");
|
||||
let editPublisher = $state("");
|
||||
let editDescription = $state("");
|
||||
let editPlatform = $state("");
|
||||
let editExecutable = $state("");
|
||||
|
||||
// IGDB scrape state
|
||||
let scraping = $state(false);
|
||||
let scrapeError = $state("");
|
||||
let igdbResults = $state(null);
|
||||
let igdbQuery = $state("");
|
||||
let applying = $state(null); // igdb_id being applied
|
||||
|
||||
// Media selection — tracks which media item is shown in the media container
|
||||
let selectedMedia = $state(null);
|
||||
|
||||
function selectMedia(type, idOrUrl) {
|
||||
if (type === 'video') {
|
||||
selectedMedia = { type: 'video', id: idOrUrl };
|
||||
} else if (type === 'screenshot') {
|
||||
selectedMedia = { type: 'screenshot', url: idOrUrl };
|
||||
}
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
downloadGame(id);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
try {
|
||||
game = await getGame(id);
|
||||
igdbQuery = game.title || "";
|
||||
// Set initial selected media to first available item
|
||||
if (game.videos?.length) {
|
||||
selectedMedia = { type: 'video', id: game.videos[0] };
|
||||
} else if (game.screenshots?.length) {
|
||||
selectedMedia = { type: 'screenshot', url: game.screenshots[0] };
|
||||
}
|
||||
} catch (e) {
|
||||
error = "Game not found";
|
||||
}
|
||||
loading = false;
|
||||
}
|
||||
|
||||
function startEdit() {
|
||||
editTitle = game.title;
|
||||
editYear = game.year || 0;
|
||||
editGenre = game.genre || "";
|
||||
editDeveloper = game.developer || "";
|
||||
editPublisher = game.publisher || "";
|
||||
editDescription = game.description || "";
|
||||
editPlatform = game.platform || "";
|
||||
editExecutable = game.executable || "";
|
||||
editing = true;
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
saving = true;
|
||||
try {
|
||||
game = await updateGame(id, {
|
||||
title: editTitle,
|
||||
year: editYear || undefined,
|
||||
genre: editGenre || undefined,
|
||||
developer: editDeveloper || undefined,
|
||||
publisher: editPublisher || undefined,
|
||||
description: editDescription || undefined,
|
||||
platform: editPlatform || undefined,
|
||||
executable: editExecutable || undefined,
|
||||
});
|
||||
editing = false;
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
}
|
||||
saving = false;
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm(`Delete "${game.title}"? This cannot be undone.`)) return;
|
||||
try {
|
||||
await deleteGame(id);
|
||||
push("/");
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
function handlePlay() {
|
||||
push(`/play/${id}`);
|
||||
}
|
||||
|
||||
function handleSetup() {
|
||||
push(`/play/${id}?setup=1`);
|
||||
}
|
||||
|
||||
// ─── IGDB Scrape ───────────────────────────────────────────
|
||||
|
||||
async function handleSearchIGDB() {
|
||||
const query = igdbQuery.trim() || game.title;
|
||||
if (!query) return;
|
||||
scraping = true;
|
||||
scrapeError = "";
|
||||
igdbResults = null;
|
||||
try {
|
||||
const data = await searchIGDB(query);
|
||||
igdbResults = data.results || [];
|
||||
if (igdbResults.length === 0) {
|
||||
scrapeError = "No results found on IGDB. Try a different title.";
|
||||
}
|
||||
} catch (e) {
|
||||
scrapeError = "IGDB search failed: " + e.message;
|
||||
}
|
||||
scraping = false;
|
||||
}
|
||||
|
||||
async function handleAutoScrape() {
|
||||
scraping = true;
|
||||
scrapeError = "";
|
||||
try {
|
||||
game = await scrapeIGDB(id);
|
||||
scrapeError = game.has_cover || game.year ? "Metadata updated from IGDB!" : "No matching game found on IGDB.";
|
||||
} catch (e) {
|
||||
scrapeError = "IGDB scrape failed: " + e.message;
|
||||
}
|
||||
scraping = false;
|
||||
}
|
||||
|
||||
async function handleApplyScrape(igdbId) {
|
||||
applying = igdbId;
|
||||
scrapeError = "";
|
||||
try {
|
||||
game = await scrapeIGDB(id, igdbId);
|
||||
igdbResults = null; // clear results after applying
|
||||
scrapeError = "Game metadata updated!";
|
||||
} catch (e) {
|
||||
scrapeError = "Failed to apply IGDB data: " + e.message;
|
||||
}
|
||||
applying = null;
|
||||
}
|
||||
|
||||
$effect(() => { load(); });
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="loading">Loading...</div>
|
||||
{:else if error && !game}
|
||||
<div class="error">{error}</div>
|
||||
<a href="#/" class="back-link">← Back to Library</a>
|
||||
{:else if game}
|
||||
<div class="detail">
|
||||
<a href="#/" class="back-link">← Back to Library</a>
|
||||
|
||||
<div class="detail-layout">
|
||||
<!-- Cover + Meta + Developer -->
|
||||
<div class="cover-section">
|
||||
{#if game.has_cover}
|
||||
<img src={`/games/${game.id}/cover.jpg`} alt={game.title} class="cover-img" />
|
||||
{:else}
|
||||
<div class="cover-placeholder">💾</div>
|
||||
{/if}
|
||||
{#if !editing}
|
||||
{#if game.year || game.genre}
|
||||
<div class="meta">
|
||||
{#if game.year}
|
||||
<a href="#/?year={game.year}" class="meta-item meta-link">{game.year}</a>
|
||||
{/if}
|
||||
{#if game.genre}
|
||||
<a href="#/?genre={encodeURIComponent(game.genre)}" class="meta-item meta-link">{game.genre}</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if game.developer}
|
||||
<p class="dev">by {game.developer}{game.publisher ? ` · ${game.publisher}` : ""}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="info-section">
|
||||
{#if editing}
|
||||
<input type="text" bind:value={editTitle} placeholder="Title" class="edit-title" />
|
||||
<div class="edit-row">
|
||||
<input type="number" bind:value={editYear} placeholder="Year" style="width:100px" />
|
||||
<input type="text" bind:value={editGenre} placeholder="Genre" style="flex:1" />
|
||||
</div>
|
||||
<div class="edit-row">
|
||||
<input type="text" bind:value={editDeveloper} placeholder="Developer" style="flex:1" />
|
||||
<input type="text" bind:value={editPublisher} placeholder="Publisher" style="flex:1" />
|
||||
</div>
|
||||
<textarea bind:value={editDescription} placeholder="Description" rows="4"></textarea>
|
||||
<div class="edit-row">
|
||||
<select bind:value={editPlatform} style="flex:1">
|
||||
<option value="">Auto (unknown)</option>
|
||||
<option value="dos">DOS</option>
|
||||
<option value="windows">Windows</option>
|
||||
</select>
|
||||
</div>
|
||||
{#if game.executables && game.executables.length > 0}
|
||||
<div class="edit-exec-section">
|
||||
<span class="edit-exec-label">Main executable:</span>
|
||||
<div class="edit-exec-list">
|
||||
{#each game.executables as exe}
|
||||
<label class="edit-exec-option" class:selected={editExecutable === exe}>
|
||||
<input
|
||||
type="radio"
|
||||
name="executable"
|
||||
value={exe}
|
||||
checked={editExecutable === exe}
|
||||
onchange={() => editExecutable = exe}
|
||||
/>
|
||||
<span class="edit-exec-path">{exe}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="edit-actions">
|
||||
<button class="btn btn-primary" onclick={saveEdit} disabled={saving}>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
<button class="btn" onclick={() => editing = false}>Cancel</button>
|
||||
</div>
|
||||
{:else}
|
||||
<h1>{game.title}</h1>
|
||||
{#if game.platform}
|
||||
<div class="platform-badge" class:platform-win={game.platform === 'windows'}>
|
||||
{#if game.platform === 'windows'}
|
||||
🪟 Requires Windows 3.1 — may not work in browser
|
||||
{:else}
|
||||
💾 DOS
|
||||
{#if game.bundle_size}
|
||||
<span class="size-badge">· {game.bundle_size >= 1073741824
|
||||
? (game.bundle_size / 1073741824).toFixed(1) + ' GB'
|
||||
: game.bundle_size >= 1048576
|
||||
? Math.round(game.bundle_size / 1048576) + ' MB'
|
||||
: Math.round(game.bundle_size / 1024) + ' KB'}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if game.description}
|
||||
<p class="description">{game.description}</p>
|
||||
{/if}
|
||||
|
||||
<div class="actions">
|
||||
{#if game.ready && (game.platform !== 'windows')}
|
||||
<button class="btn btn-primary" onclick={handlePlay}>▶ Play</button>
|
||||
{:else if game.ready && game.platform === 'windows'}
|
||||
<button class="btn btn-primary btn-disabled" disabled title="Windows games need Windows 3.1 emulation (not yet supported)">
|
||||
▶ Unplayable
|
||||
</button>
|
||||
{/if}
|
||||
{#if game.has_setup && game.platform !== 'windows'}
|
||||
<button class="btn btn-setup" onclick={handleSetup}>🛠 Setup</button>
|
||||
{/if}
|
||||
<button class="btn" onclick={startEdit}>✏ Edit</button>
|
||||
<button class="btn" onclick={handleDownload}>⬇ Download</button>
|
||||
<button class="btn btn-danger" onclick={handleDelete}>✕ Delete</button>
|
||||
</div>
|
||||
|
||||
<!-- Media Section — container + clickable row for all videos & screenshots -->
|
||||
{#if selectedMedia}
|
||||
<div class="media-section">
|
||||
<span class="media-label">📺 Media</span>
|
||||
<div class="media-container">
|
||||
{#if selectedMedia.type === 'video'}
|
||||
<iframe
|
||||
src="https://www.youtube.com/embed/{selectedMedia.id}"
|
||||
title="Gameplay video"
|
||||
allowfullscreen
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
></iframe>
|
||||
{:else if selectedMedia.type === 'screenshot'}
|
||||
<img src={selectedMedia.url} alt="Screenshot" class="media-main-img" />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="media-row">
|
||||
{#each game.videos ?? [] as video, i}
|
||||
<button
|
||||
class="media-thumb"
|
||||
class:active={selectedMedia.type === 'video' && selectedMedia.id === video}
|
||||
onclick={() => selectMedia('video', video)}
|
||||
title="Video {i + 1}"
|
||||
>
|
||||
<img src="https://img.youtube.com/vi/{video}/mqdefault.jpg" alt="Video {i + 1}" loading="lazy" />
|
||||
<span class="media-thumb-icon">▶</span>
|
||||
</button>
|
||||
{/each}
|
||||
{#each game.screenshots ?? [] as shot}
|
||||
<button
|
||||
class="media-thumb"
|
||||
class:active={selectedMedia.type === 'screenshot' && selectedMedia.url === shot}
|
||||
onclick={() => selectMedia('screenshot', shot)}
|
||||
title="Screenshot"
|
||||
>
|
||||
<img src={shot} alt="Screenshot" loading="lazy" />
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- IGDB Scrape Section -->
|
||||
<div class="scrape-section">
|
||||
<div class="scrape-header">
|
||||
<span class="scrape-label">📡 IGDB Metadata</span>
|
||||
<div class="scrape-buttons">
|
||||
<button class="btn btn-sm" onclick={handleAutoScrape} disabled={scraping}>
|
||||
{scraping ? "Searching..." : "Auto Scrape"}
|
||||
</button>
|
||||
<button class="btn btn-sm" onclick={handleSearchIGDB} disabled={scraping}>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if igdbResults !== null}
|
||||
<div class="igdb-query-row">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={igdbQuery}
|
||||
placeholder="Search IGDB..."
|
||||
onkeydown={(e) => e.key === "Enter" && handleSearchIGDB()}
|
||||
/>
|
||||
</div>
|
||||
{#if igdbResults.length > 0}
|
||||
<div class="igdb-results">
|
||||
{#each igdbResults as result (result.igdb_id)}
|
||||
<button
|
||||
class="igdb-result"
|
||||
onclick={() => handleApplyScrape(result.igdb_id)}
|
||||
disabled={applying === result.igdb_id}
|
||||
>
|
||||
{#if result.cover_url}
|
||||
<img
|
||||
src={result.cover_url}
|
||||
alt={result.name}
|
||||
class="igdb-thumb"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<div class="igdb-thumb-placeholder">🎮</div>
|
||||
{/if}
|
||||
<div class="igdb-info">
|
||||
<strong>{result.name}</strong>
|
||||
<span class="igdb-meta">
|
||||
{result.year || "—"}
|
||||
{#if result.is_dos}<span class="dos-badge">DOS</span>{/if}
|
||||
{#if result.genres?.length}
|
||||
· {result.genres.slice(0, 2).join(", ")}
|
||||
{/if}
|
||||
</span>
|
||||
{#if result.developer}
|
||||
<span class="igdb-dev">{result.developer}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if scrapeError}
|
||||
<div class="scrape-status" class:scrape-ok={scrapeError.includes("updated")}>
|
||||
{scrapeError}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.detail { padding: 32px 0; }
|
||||
.loading { text-align: center; padding: 60px; color: var(--text-dim); }
|
||||
.error { background: #331111; border: 1px solid #cc3333; color: #ff6666; padding: 12px; border-radius: var(--radius-sm); margin-bottom: 16px; }
|
||||
.back-link { display: inline-block; margin-bottom: 24px; color: var(--text-dim); }
|
||||
.back-link:hover { color: var(--phosphor); }
|
||||
.detail-layout { display: grid; grid-template-columns: 300px 1fr; gap: 32px; align-items: start; }
|
||||
.detail-layout > * { min-width: 0; } /* prevent grid overflow from wide content */
|
||||
@media (max-width: 640px) { .detail-layout { grid-template-columns: 1fr; } }
|
||||
.cover-section { border-radius: var(--radius); overflow: hidden; }
|
||||
.cover-section .meta { margin-top: 12px; }
|
||||
.cover-section .dev { margin-top: 8px; }
|
||||
.cover-img { width: 100%; border-radius: var(--radius); }
|
||||
.cover-placeholder { aspect-ratio: 3/4; background: var(--surface); display: flex; align-items: center; justify-content: center; font-size: 4rem; border-radius: var(--radius); }
|
||||
.info-section h1 { color: var(--phosphor); font-family: var(--font-mono); margin-bottom: 8px; word-break: break-word; }
|
||||
.platform-badge {
|
||||
display: inline-block;
|
||||
font-size: 0.8rem;
|
||||
padding: 3px 10px;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
background: var(--phosphor-dark);
|
||||
color: var(--phosphor);
|
||||
}
|
||||
.platform-badge.platform-win {
|
||||
background: #1a3a5c;
|
||||
color: #6ab0ff;
|
||||
}
|
||||
.size-badge {
|
||||
font-weight: 400;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.meta { display: flex; gap: 8px; margin-bottom: 8px; }
|
||||
.meta-item { background: var(--surface-hover); color: var(--phosphor-dim); padding: 4px 12px; border-radius: 12px; font-size: 0.85rem; }
|
||||
.meta-link { cursor: pointer; text-decoration: none; transition: all 0.15s; display: inline-block; }
|
||||
.meta-link:hover { background: var(--phosphor-dark); color: var(--phosphor); }
|
||||
.dev { color: var(--text-dim); margin-bottom: 16px; }
|
||||
.description { color: var(--text); line-height: 1.7; margin-bottom: 24px; overflow-wrap: break-word; word-break: break-word; }
|
||||
.actions { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 24px; }
|
||||
.edit-title { font-size: 1.5rem; margin-bottom: 12px; width: 100%; }
|
||||
.edit-row { display: flex; gap: 8px; margin-bottom: 8px; }
|
||||
.edit-actions { display: flex; gap: 8px; margin-top: 12px; }
|
||||
textarea { width: 100%; min-height: 100px; }
|
||||
|
||||
/* Media Section — container + clickable thumbnail row */
|
||||
.media-section {
|
||||
margin-top: 24px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.media-label {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-dim);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.media-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.media-container iframe {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.media-main-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: #000;
|
||||
}
|
||||
.media-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
.media-thumb {
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
width: 200px;
|
||||
height: 113px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 2px solid var(--border);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
background: none;
|
||||
transition: border-color 0.15s, opacity 0.15s;
|
||||
}
|
||||
.media-thumb:hover {
|
||||
border-color: var(--phosphor-dim);
|
||||
opacity: 0.9;
|
||||
}
|
||||
.media-thumb.active {
|
||||
border-color: var(--phosphor);
|
||||
}
|
||||
.media-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.media-thumb-icon {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
left: 4px;
|
||||
background: rgba(0,0,0,0.7);
|
||||
color: #fff;
|
||||
font-size: 0.7rem;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* IGDB Scrape */
|
||||
.scrape-section {
|
||||
margin-top: 24px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.scrape-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.scrape-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-dim);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.scrape-buttons {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.btn-sm {
|
||||
padding: 4px 12px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.igdb-query-row {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.igdb-query-row input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.igdb-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.igdb-result {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 0.15s;
|
||||
width: 100%;
|
||||
}
|
||||
.igdb-result:hover {
|
||||
border-color: var(--phosphor-dim);
|
||||
}
|
||||
.igdb-result:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
}
|
||||
.igdb-thumb {
|
||||
width: 48px;
|
||||
height: 64px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.igdb-thumb-placeholder {
|
||||
width: 48px;
|
||||
height: 64px;
|
||||
background: var(--surface-hover);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.igdb-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.igdb-info strong {
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-bright);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.igdb-meta {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.dos-badge {
|
||||
background: var(--phosphor-dark);
|
||||
color: var(--phosphor);
|
||||
padding: 1px 6px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.igdb-dev {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.scrape-status {
|
||||
margin-top: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.85rem;
|
||||
background: #221100;
|
||||
color: #ff9933;
|
||||
}
|
||||
.scrape-status.scrape-ok {
|
||||
background: #112211;
|
||||
color: #66ff66;
|
||||
}
|
||||
|
||||
/* ─── Executable Picker ────────────────────── */
|
||||
.edit-exec-section {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.edit-exec-label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-dim);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.edit-exec-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.edit-exec-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-family: var(--font-mono);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.edit-exec-option:hover {
|
||||
border-color: var(--phosphor-dim);
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
.edit-exec-option.selected {
|
||||
border-color: var(--phosphor);
|
||||
background: var(--phosphor-burn);
|
||||
}
|
||||
.edit-exec-option input[type="radio"] {
|
||||
accent-color: var(--phosphor);
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.edit-exec-path {
|
||||
word-break: break-all;
|
||||
color: var(--text);
|
||||
}
|
||||
.edit-exec-option.selected .edit-exec-path {
|
||||
color: var(--phosphor-glow);
|
||||
}
|
||||
|
||||
/* ─── Responsive ────────────────────────────────── */
|
||||
@media (max-width: 640px) {
|
||||
.detail { padding: 16px 0; }
|
||||
.detail-layout { gap: 16px; }
|
||||
.cover-section { max-width: 200px; }
|
||||
.cover-placeholder { font-size: 3rem; }
|
||||
.media-thumb { width: 160px; height: 90px; }
|
||||
.scrape-header { flex-direction: column; align-items: flex-start; gap: 8px; }
|
||||
.scrape-buttons { width: 100%; }
|
||||
.scrape-buttons .btn { flex: 1; justify-content: center; }
|
||||
.edit-row { flex-direction: column; }
|
||||
.edit-row input { width: 100% !important; }
|
||||
}
|
||||
.btn-setup {
|
||||
border-color: var(--phosphor-dark);
|
||||
color: var(--phosphor);
|
||||
}
|
||||
.btn-setup:hover {
|
||||
background: var(--phosphor-burn);
|
||||
box-shadow: 0 0 12px var(--phosphor-dark);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,850 @@
|
||||
<script>
|
||||
import { listGames, uploadGame, searchIGDB } from "../lib/api.js";
|
||||
import GameCard from "../lib/GameCard.svelte";
|
||||
|
||||
let { genreFilter = "", yearFilter = "", uploadTriggered = 0 } = $props();
|
||||
|
||||
let games = $state([]);
|
||||
let loading = $state(true);
|
||||
let search = $state("");
|
||||
let uploading = $state(false);
|
||||
let uploadError = $state("");
|
||||
|
||||
// Post-selection upload dialog
|
||||
let pendingFile = $state(null);
|
||||
let pendingFileName = $state("");
|
||||
let pendingTitle = $state("");
|
||||
let searching = $state(false);
|
||||
let searchError = $state("");
|
||||
let igdbResults = $state(null);
|
||||
let manualSearched = $state(false);
|
||||
let duplicateError = $state("");
|
||||
|
||||
// Filter state
|
||||
let selectedGenres = $state(new Set());
|
||||
let selectedYears = $state(new Set());
|
||||
let showAllGenres = $state(false);
|
||||
let showAllYears = $state(false);
|
||||
|
||||
let fileInput;
|
||||
|
||||
// When header upload button is clicked, trigger the hidden file input.
|
||||
// Uses a previous-value guard to prevent re-firing on component remount.
|
||||
let prevUploadTrigger = $state(uploadTriggered);
|
||||
$effect(() => {
|
||||
if (uploadTriggered > 0 && uploadTriggered !== prevUploadTrigger) {
|
||||
prevUploadTrigger = uploadTriggered;
|
||||
fileInput?.click();
|
||||
}
|
||||
});
|
||||
|
||||
// Derive filter options from the loaded games
|
||||
let filterOptions = $derived.by(() => {
|
||||
const years = {};
|
||||
const genres = {};
|
||||
for (const g of games) {
|
||||
if (g.year) {
|
||||
const y = String(g.year);
|
||||
years[y] = (years[y] || 0) + 1;
|
||||
}
|
||||
if (g.genre) {
|
||||
genres[g.genre] = (genres[g.genre] || 0) + 1;
|
||||
}
|
||||
}
|
||||
return {
|
||||
years: Object.entries(years)
|
||||
.map(([label, count]) => ({ label, count }))
|
||||
.sort((a, b) => Number(b.label) - Number(a.label)),
|
||||
genres: Object.entries(genres)
|
||||
.map(([label, count]) => ({ label, count }))
|
||||
.sort((a, b) => b.count - a.count),
|
||||
};
|
||||
});
|
||||
|
||||
// Apply initial filter values from URL params
|
||||
$effect(() => {
|
||||
if (genreFilter) selectedGenres = new Set([genreFilter]);
|
||||
if (yearFilter) selectedYears = new Set([yearFilter]);
|
||||
});
|
||||
|
||||
// Update URL hash when filters change (but not on initial load)
|
||||
let initialFilter = true;
|
||||
$effect(() => {
|
||||
if (initialFilter) { initialFilter = false; return; }
|
||||
const params = new URLSearchParams();
|
||||
if (selectedGenres.size === 1) params.set("genre", [...selectedGenres][0]);
|
||||
if (selectedYears.size === 1) params.set("year", [...selectedYears][0]);
|
||||
const qs = params.toString();
|
||||
const base = window.location.hash.split("?")[0] || "#/";
|
||||
const newHash = qs ? base + "?" + qs : base;
|
||||
if (window.location.hash !== newHash) {
|
||||
window.location.hash = newHash;
|
||||
}
|
||||
});
|
||||
|
||||
// Filtered games
|
||||
let filteredGames = $derived.by(() => {
|
||||
let list = games;
|
||||
if (selectedGenres.size > 0) {
|
||||
list = list.filter(g => g.genre && selectedGenres.has(g.genre));
|
||||
}
|
||||
if (selectedYears.size > 0) {
|
||||
list = list.filter(g => g.year && selectedYears.has(String(g.year)));
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
function toggleFilter(set, value) {
|
||||
const next = new Set(set);
|
||||
if (next.has(value)) next.delete(value);
|
||||
else next.add(value);
|
||||
return next;
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
selectedGenres = new Set();
|
||||
selectedYears = new Set();
|
||||
showAllGenres = false;
|
||||
showAllYears = false;
|
||||
window.location.hash = "#/";
|
||||
}
|
||||
|
||||
function hasActiveFilters() {
|
||||
return selectedGenres.size > 0 || selectedYears.size > 0;
|
||||
}
|
||||
|
||||
async function loadGames() {
|
||||
loading = true;
|
||||
try {
|
||||
const data = await listGames(search ? { search } : {});
|
||||
games = data.games || [];
|
||||
} catch (e) {
|
||||
console.error("Failed to load games:", e);
|
||||
}
|
||||
loading = false;
|
||||
}
|
||||
|
||||
function handleFileSelected(e) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const name = file.name.replace(/\.zip$/i, "");
|
||||
pendingFile = file;
|
||||
pendingFileName = name;
|
||||
pendingTitle = name;
|
||||
igdbResults = null;
|
||||
searching = true;
|
||||
searchError = "";
|
||||
manualSearched = false;
|
||||
duplicateError = "";
|
||||
fileInput.value = "";
|
||||
searchIGDB(name).then(data => {
|
||||
igdbResults = data.results || [];
|
||||
}).catch(err => {
|
||||
searchError = "IGDB search failed: " + err.message;
|
||||
igdbResults = [];
|
||||
}).finally(() => {
|
||||
searching = false;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleManualSearch() {
|
||||
const q = pendingTitle.trim();
|
||||
if (!q) return;
|
||||
manualSearched = true;
|
||||
searching = true;
|
||||
searchError = "";
|
||||
igdbResults = null;
|
||||
try {
|
||||
const data = await searchIGDB(q);
|
||||
igdbResults = data.results || [];
|
||||
if (igdbResults.length === 0) {
|
||||
searchError = 'No matches found for "' + q + '" on IGDB.';
|
||||
}
|
||||
} catch (err) {
|
||||
searchError = "IGDB search failed: " + err.message;
|
||||
igdbResults = [];
|
||||
}
|
||||
searching = false;
|
||||
}
|
||||
|
||||
function cancelUpload() {
|
||||
pendingFile = null;
|
||||
pendingFileName = "";
|
||||
pendingTitle = "";
|
||||
igdbResults = null;
|
||||
searchError = "";
|
||||
manualSearched = false;
|
||||
duplicateError = "";
|
||||
}
|
||||
|
||||
async function doUpload(title, igdbId) {
|
||||
if (!pendingFile) return;
|
||||
|
||||
const finalTitle = title || pendingFileName;
|
||||
if (finalTitle) {
|
||||
const dup = games.find(g => g.title?.toLowerCase() === finalTitle.toLowerCase());
|
||||
if (dup) {
|
||||
duplicateError = `"${dup.title}" is already in your library`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
uploading = true;
|
||||
uploadError = "";
|
||||
duplicateError = "";
|
||||
try {
|
||||
await uploadGame(pendingFile, title || undefined, igdbId);
|
||||
cancelUpload();
|
||||
await loadGames();
|
||||
} catch (err) {
|
||||
uploadError = err.message;
|
||||
}
|
||||
uploading = false;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadGames();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="library-layout">
|
||||
<!-- ─── Filter Sidebar ─────────────────────────── -->
|
||||
<aside class="sidebar" class:sidebar-active={hasActiveFilters()}>
|
||||
|
||||
<!-- Search box -->
|
||||
<div class="sidebar-search">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search games..."
|
||||
bind:value={search}
|
||||
oninput={() => loadGames()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Year filter group -->
|
||||
<div class="filter-group">
|
||||
<h3 class="filter-label">Year</h3>
|
||||
{#if filterOptions.years.length === 0}
|
||||
<p class="filter-empty">—</p>
|
||||
{:else}
|
||||
{#each (showAllYears ? filterOptions.years : filterOptions.years.slice(0, 5)) as opt}
|
||||
<label class="filter-option">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedYears.has(opt.label)}
|
||||
onchange={() => selectedYears = toggleFilter(selectedYears, opt.label)}
|
||||
/>
|
||||
<span class="filter-text">{opt.label}</span>
|
||||
<span class="filter-count">{opt.count}</span>
|
||||
</label>
|
||||
{/each}
|
||||
{#if filterOptions.years.length > 5}
|
||||
<button class="btn btn-link" onclick={() => showAllYears = !showAllYears}>
|
||||
{showAllYears ? "△ Show less" : `▾ Show all (${filterOptions.years.length})`}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Genre filter group -->
|
||||
<div class="filter-group">
|
||||
<h3 class="filter-label">Genre</h3>
|
||||
{#if filterOptions.genres.length === 0}
|
||||
<p class="filter-empty">—</p>
|
||||
{:else}
|
||||
{#each (showAllGenres ? filterOptions.genres : filterOptions.genres.slice(0, 5)) as opt}
|
||||
<label class="filter-option">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedGenres.has(opt.label)}
|
||||
onchange={() => selectedGenres = toggleFilter(selectedGenres, opt.label)}
|
||||
/>
|
||||
<span class="filter-text">{opt.label}</span>
|
||||
<span class="filter-count">{opt.count}</span>
|
||||
</label>
|
||||
{/each}
|
||||
{#if filterOptions.genres.length > 5}
|
||||
<button class="btn btn-link" onclick={() => showAllGenres = !showAllGenres}>
|
||||
{showAllGenres ? "△ Show less" : `▾ Show all (${filterOptions.genres.length})`}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if hasActiveFilters()}
|
||||
<div class="sidebar-reset">
|
||||
<button class="btn btn-sm btn-reset" onclick={resetFilters}>✕ Reset</button>
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
<!-- ─── Main Content ──────────────────────────── -->
|
||||
<div class="library-main">
|
||||
|
||||
<!-- Active filter chips -->
|
||||
{#if hasActiveFilters()}
|
||||
<div class="active-filters">
|
||||
{#each [...selectedYears] as y}
|
||||
<button class="filter-chip" onclick={() => selectedYears = toggleFilter(selectedYears, y)}>
|
||||
{y} ✕
|
||||
</button>
|
||||
{/each}
|
||||
{#each [...selectedGenres] as g}
|
||||
<button class="filter-chip" onclick={() => selectedGenres = toggleFilter(selectedGenres, g)}>
|
||||
{g} ✕
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if pendingFile}
|
||||
<div class="upload-overlay" onclick={cancelUpload} role="presentation">
|
||||
<div class="upload-dialog" onclick={(e) => e.stopPropagation()} role="dialog">
|
||||
<h3>Upload Game</h3>
|
||||
<p class="upload-file-name">{pendingFileName}.zip</p>
|
||||
|
||||
<div class="upload-dialog-body">
|
||||
{#if uploading}
|
||||
<div class="upload-progress">
|
||||
<div class="upload-progress-spinner"></div>
|
||||
<p class="upload-progress-text">Uploading and processing</p>
|
||||
<p class="upload-progress-sub">{pendingFileName}.zip</p>
|
||||
<p class="upload-progress-note">This may take a while for large games</p>
|
||||
</div>
|
||||
{:else if duplicateError}
|
||||
<div class="status-msg error-msg">
|
||||
⚠️ {duplicateError}
|
||||
</div>
|
||||
{:else if searching}
|
||||
<div class="status-msg">Searching IGDB for "{pendingFileName}"...</div>
|
||||
{:else if igdbResults !== null && igdbResults.length > 0}
|
||||
<p class="match-label">Select a match or enter a custom title:</p>
|
||||
<div class="upload-igdb-results">
|
||||
{#each igdbResults as result}
|
||||
<button
|
||||
class="igdb-result"
|
||||
data-name={result.name}
|
||||
data-igdb-id={result.igdb_id}
|
||||
onclick={(e) => doUpload(e.currentTarget.dataset.name, Number(e.currentTarget.dataset.igdbId))}
|
||||
disabled={uploading}
|
||||
>
|
||||
{#if result.cover_url}
|
||||
<img src={result.cover_url} alt={result.name} class="igdb-thumb" loading="lazy" />
|
||||
{:else}
|
||||
<div class="igdb-thumb-placeholder">🎮</div>
|
||||
{/if}
|
||||
<div class="igdb-info">
|
||||
<strong>{result.name}</strong>
|
||||
<span class="igdb-meta">
|
||||
{result.year || "—"}
|
||||
{#if result.genres?.length} · {result.genres.slice(0, 2).join(", ")}{/if}
|
||||
</span>
|
||||
{#if result.developer}
|
||||
<span class="igdb-dev">{result.developer}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="upload-custom-row">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Or type a different title..."
|
||||
bind:value={pendingTitle}
|
||||
disabled={uploading}
|
||||
onkeydown={(e) => e.key === "Enter" && handleManualSearch()}
|
||||
/>
|
||||
<button class="btn" onclick={handleManualSearch} disabled={uploading || !pendingTitle.trim()}>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
{#if searchError}
|
||||
<div class="status-msg error-msg">
|
||||
{searchError}
|
||||
{#if manualSearched}
|
||||
<button class="btn btn-sm btn-upload-anyway" onclick={() => doUpload(pendingTitle.trim())} disabled={uploading}>
|
||||
{uploading ? "Uploading..." : 'Upload anyway as "' + pendingTitle.trim() + '"'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="match-label">No matches found. Edit the title below and search IGDB:</p>
|
||||
{/if}
|
||||
<div class="upload-custom-row">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Edit title..."
|
||||
bind:value={pendingTitle}
|
||||
disabled={uploading || searching}
|
||||
onkeydown={(e) => e.key === "Enter" && handleManualSearch()}
|
||||
/>
|
||||
<button class="btn" onclick={handleManualSearch} disabled={uploading || searching || !pendingTitle.trim()}>
|
||||
{uploading ? "Uploading..." : "Search"}
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn btn-secondary" onclick={() => doUpload(pendingFileName)} disabled={uploading}>
|
||||
Use filename: {pendingFileName}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button class="btn btn-cancel" onclick={cancelUpload} disabled={uploading}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if uploadError}
|
||||
<div class="error">{uploadError}</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="loading">Scanning library...</div>
|
||||
{:else if filteredGames.length === 0}
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">💾</div>
|
||||
<h2>{hasActiveFilters() ? "No matching games" : "No games yet"}</h2>
|
||||
<p>{hasActiveFilters() ? "Try changing your filters" : "Upload a DOS game ZIP to get started"}</p>
|
||||
{#if hasActiveFilters()}
|
||||
<button class="btn btn-primary" style="margin-top: 16px;" onclick={resetFilters}>✕ Clear filters</button>
|
||||
{:else}
|
||||
<label class="btn btn-primary" style="margin-top: 16px;">
|
||||
Upload your first game
|
||||
<input type="file" accept=".zip" class="hidden-input" onchange={handleFileSelected} />
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid">
|
||||
{#each filteredGames as game (game.id)}
|
||||
<GameCard {game} />
|
||||
{/each}
|
||||
</div>
|
||||
<div class="count">{filteredGames.length} game{filteredGames.length !== 1 ? "s" : ""}</div>
|
||||
{/if}
|
||||
|
||||
<!-- Hidden file input triggered by header upload button -->
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
accept=".zip"
|
||||
class="hidden-input"
|
||||
onchange={handleFileSelected}
|
||||
disabled={uploading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* ─── Layout ────────────────────────────────── */
|
||||
.library-layout {
|
||||
display: flex;
|
||||
gap: 28px;
|
||||
padding: 32px 0;
|
||||
align-items: flex-start;
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.library-layout { flex-direction: column; padding: 16px 0; gap: 16px; }
|
||||
}
|
||||
|
||||
/* ─── Sidebar ────────────────────────────────── */
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 24px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
max-height: calc(100vh - 120px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
position: static;
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
.sidebar-search {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.sidebar-search input {
|
||||
width: 100%;
|
||||
}
|
||||
.sidebar-reset {
|
||||
margin-top: 20px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.sidebar-reset .btn-reset {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
.filter-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.filter-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.filter-empty {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.8rem;
|
||||
font-style: italic;
|
||||
}
|
||||
.filter-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 3px 0;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text);
|
||||
transition: color 0.1s;
|
||||
}
|
||||
.filter-option:hover {
|
||||
color: var(--phosphor);
|
||||
}
|
||||
.filter-option input[type="checkbox"] {
|
||||
accent-color: var(--phosphor);
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.filter-text {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.filter-count {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
background: var(--surface-hover);
|
||||
padding: 0 6px;
|
||||
border-radius: 8px;
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.btn-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--phosphor-dim);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
.btn-link:hover {
|
||||
color: var(--phosphor);
|
||||
}
|
||||
.btn-reset {
|
||||
border-color: #883333;
|
||||
color: #ff6666;
|
||||
font-size: 0.75rem;
|
||||
padding: 2px 10px;
|
||||
}
|
||||
.btn-reset:hover {
|
||||
background: #331111;
|
||||
border-color: #ff4444;
|
||||
}
|
||||
|
||||
/* ─── Active filter chips ────────────────────── */
|
||||
.active-filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.filter-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: var(--phosphor-dark);
|
||||
color: var(--phosphor);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 14px;
|
||||
padding: 3px 12px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-sans);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.filter-chip:hover {
|
||||
background: #1a4422;
|
||||
border-color: var(--phosphor-dim);
|
||||
}
|
||||
|
||||
/* ─── Main content ───────────────────────────── */
|
||||
.library-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--phosphor-dim);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
.btn-link:hover {
|
||||
color: var(--phosphor);
|
||||
}
|
||||
|
||||
.hidden-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.grid { grid-template-columns: repeat(2, 1fr); gap: 12px; }
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #331111;
|
||||
border: 1px solid #cc3333;
|
||||
color: #ff6666;
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--text-dim);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 80px 20px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.empty-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.empty-state h2 {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.empty-state p {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.count {
|
||||
margin-top: 24px;
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ─── Upload Dialog ─────────────────────────────── */
|
||||
.upload-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
padding: 16px;
|
||||
}
|
||||
.upload-dialog {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px;
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.upload-dialog h3 {
|
||||
margin-bottom: 4px;
|
||||
color: var(--phosphor);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.upload-file-name {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 4px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.upload-dialog-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
margin: 16px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.match-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.status-msg {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
font-style: italic;
|
||||
}
|
||||
.status-msg.error-msg {
|
||||
color: #ff6666;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
/* IGDB results list inside dialog */
|
||||
.upload-igdb-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.igdb-result {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
background: var(--surface-hover);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 0.15s;
|
||||
width: 100%;
|
||||
}
|
||||
.igdb-result:hover {
|
||||
border-color: var(--phosphor-dim);
|
||||
}
|
||||
.igdb-result:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
}
|
||||
.igdb-thumb {
|
||||
width: 42px;
|
||||
height: 56px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.igdb-thumb-placeholder {
|
||||
width: 42px;
|
||||
height: 56px;
|
||||
background: var(--surface);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.igdb-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.igdb-info strong {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-bright);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.igdb-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.igdb-dev {
|
||||
display: block;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-dim);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.upload-custom-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.upload-custom-row input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
border-color: var(--border);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.btn-cancel:hover {
|
||||
border-color: var(--text-dim);
|
||||
color: var(--text);
|
||||
}
|
||||
.btn-secondary {
|
||||
border-color: var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
border-color: var(--text-dim);
|
||||
}
|
||||
.btn-upload-anyway {
|
||||
margin-top: 8px;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
border-color: #cc8833;
|
||||
color: #ffaa44;
|
||||
}
|
||||
.btn-upload-anyway:hover {
|
||||
background: #332211;
|
||||
border-color: #ffaa44;
|
||||
}
|
||||
|
||||
/* Upload Progress spinner */
|
||||
.upload-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.upload-progress-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--border);
|
||||
border-top: 3px solid var(--phosphor);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.upload-progress-text {
|
||||
color: var(--text);
|
||||
font-size: 1rem;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.upload-progress-sub {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.upload-progress-note {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.75rem;
|
||||
margin-top: 8px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,300 @@
|
||||
<script>
|
||||
import { getGame, bundleUrl, setupBundleUrl } from "../lib/api.js";
|
||||
import { push } from "../lib/router.js";
|
||||
|
||||
let { id } = $props();
|
||||
|
||||
// Read query params from the hash — window.location is available after mount
|
||||
let isSetup = $state(false);
|
||||
|
||||
let game = $state(null);
|
||||
let loading = $state(true);
|
||||
let error = $state("");
|
||||
let booting = $state(false);
|
||||
let running = $state(false);
|
||||
let unsupported = $state(false);
|
||||
let started = false;
|
||||
|
||||
let dosContainer;
|
||||
let dosCI = null;
|
||||
let injectedLink = null;
|
||||
let injectedScript = null;
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
// Detect ?setup=1 from the hash-based URL
|
||||
isSetup = window.location.hash.includes("setup=1");
|
||||
try {
|
||||
game = await getGame(id);
|
||||
// Windows games can't run in pure DOSBox — flag it
|
||||
if (game.platform === 'windows') {
|
||||
unsupported = true;
|
||||
}
|
||||
} catch (e) {
|
||||
error = "Game not found";
|
||||
}
|
||||
loading = false;
|
||||
}
|
||||
|
||||
async function startEmulator() {
|
||||
if (started || !game?.ready || !dosContainer) return;
|
||||
started = true;
|
||||
booting = true;
|
||||
|
||||
// Load js-dos CSS
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = "https://v8.js-dos.com/latest/js-dos.css";
|
||||
document.head.appendChild(link);
|
||||
injectedLink = link;
|
||||
|
||||
// Load js-dos script from CDN
|
||||
if (!window.Dos) {
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const s = document.createElement("script");
|
||||
s.src = "https://v8.js-dos.com/latest/js-dos.js";
|
||||
s.onload = resolve;
|
||||
s.onerror = reject;
|
||||
document.head.appendChild(s);
|
||||
injectedScript = s;
|
||||
});
|
||||
} catch {
|
||||
error = "Failed to load emulator. Check your internet connection.";
|
||||
booting = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Start the DOS emulator
|
||||
try {
|
||||
const url = isSetup ? setupBundleUrl(game) : bundleUrl(game);
|
||||
if (!url || !window.Dos) throw new Error("Failed to resolve game bundle");
|
||||
dosCI = await window.Dos(dosContainer, { url });
|
||||
running = true;
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
}
|
||||
booting = false;
|
||||
}
|
||||
|
||||
function stopEmulator() {
|
||||
// Force full page reload which terminates all workers, AudioContexts
|
||||
// and WASM threads. Hash is preserved so the SPA routes correctly.
|
||||
window.location.hash = `#/game/${game.id}`;
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
function handleTryAnyway() {
|
||||
unsupported = false;
|
||||
startEmulator();
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
push(`/game/${id}`);
|
||||
}
|
||||
|
||||
$effect(() => { load(); });
|
||||
|
||||
// Auto-start when both game data and the container exist
|
||||
$effect(() => {
|
||||
if (game?.ready && dosContainer && !started && !unsupported) {
|
||||
startEmulator();
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup emulator on component unmount (browser back, nav away)
|
||||
$effect(() => {
|
||||
return () => {
|
||||
if (dosCI) {
|
||||
try { dosCI.exit(); } catch (_) {}
|
||||
dosCI = null;
|
||||
}
|
||||
if (dosContainer) {
|
||||
dosContainer.innerHTML = "";
|
||||
}
|
||||
// Remove injected js-dos CSS so it doesn't corrupt other pages
|
||||
if (injectedLink && injectedLink.parentNode) {
|
||||
injectedLink.parentNode.removeChild(injectedLink);
|
||||
injectedLink = null;
|
||||
}
|
||||
if (injectedScript && injectedScript.parentNode) {
|
||||
injectedScript.parentNode.removeChild(injectedScript);
|
||||
injectedScript = null;
|
||||
}
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="play-page">
|
||||
<!-- Top bar: shown when game is loaded -->
|
||||
<div class="play-header" class:active={game}>
|
||||
{#if game}
|
||||
<button class="link-button" onclick={stopEmulator}>
|
||||
← {isSetup ? "Back" : game.title}
|
||||
</button>
|
||||
{#if running}
|
||||
<button class="btn stop-btn" onclick={stopEmulator}>⏹ Stop</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
{#if error && !booting}
|
||||
<div class="error-box">{error}</div>
|
||||
{/if}
|
||||
|
||||
<!-- Unsupported platform warning -->
|
||||
{#if unsupported}
|
||||
<div class="overlay">
|
||||
<div class="overlay-icon">🪟</div>
|
||||
<h2>Windows game</h2>
|
||||
<p><strong>{game.title}</strong> is a Windows application and won't run in the browser DOS emulator without Windows 3.1 installed.</p>
|
||||
<div class="unsupported-actions">
|
||||
<button class="btn btn-secondary" onclick={goBack}>← Back</button>
|
||||
<button class="btn" onclick={handleTryAnyway}>Try anyway</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Loading / booting overlay (covers the canvas while loading) -->
|
||||
{#if !running}
|
||||
<div class="overlay">
|
||||
{#if loading}
|
||||
<div class="overlay-icon">⏳</div>
|
||||
<p>Loading game data...</p>
|
||||
{:else if booting}
|
||||
<div class="overlay-icon">⚙️</div>
|
||||
<h2>{game?.title || ""}</h2>
|
||||
{#if isSetup}
|
||||
<p>Starting setup utility...</p>
|
||||
<p class="save-hint">⚙️ Configure controls, then exit SETUP to save</p>
|
||||
{:else}
|
||||
<p>Starting emulator...</p>
|
||||
<p class="save-hint">💾 Game saves are stored automatically</p>
|
||||
{/if}
|
||||
{:else if error}
|
||||
<div class="overlay-icon">⚠️</div>
|
||||
<h2>Failed to start</h2>
|
||||
<p>{error}</p>
|
||||
{:else if !game?.ready}
|
||||
<div class="overlay-icon">🕹️</div>
|
||||
<h2>Not ready</h2>
|
||||
<p>This game hasn't been processed yet.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- dos-container is always in the DOM so bind:this works on first render -->
|
||||
<div bind:this={dosContainer} class="dos-container"></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.play-page { padding: 16px 0; position: relative; }
|
||||
@media (max-width: 640px) { .play-page { padding: 8px 0; } }
|
||||
.play-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.play-header.active { opacity: 1; }
|
||||
.link-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
.link-button:hover { color: var(--phosphor); }
|
||||
.stop-btn {
|
||||
border-color: #cc3333;
|
||||
color: #ff4444;
|
||||
}
|
||||
.stop-btn:hover {
|
||||
background: #331111;
|
||||
box-shadow: 0 0 12px #331111;
|
||||
}
|
||||
|
||||
/* Emulator canvas — always mounted */
|
||||
.dos-container {
|
||||
width: 100%;
|
||||
aspect-ratio: 4/3;
|
||||
max-height: 80vh;
|
||||
background: #000;
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.dos-container { aspect-ratio: 4/3; max-height: 50vh; border-radius: var(--radius-sm); }
|
||||
}
|
||||
.dos-container :global(canvas) {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
/* Overlay shown while loading / booting, covers the empty container */
|
||||
.overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
top: 50px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
background: var(--bg);
|
||||
z-index: 10;
|
||||
border-radius: var(--radius);
|
||||
border: 1px dashed var(--border);
|
||||
margin-top: 4px;
|
||||
padding: 20px;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.overlay { top: 44px; border-radius: var(--radius-sm); padding: 16px; }
|
||||
.overlay-icon { font-size: 2rem; }
|
||||
.overlay h2 { font-size: 1rem; }
|
||||
.overlay p { font-size: 0.85rem; }
|
||||
}
|
||||
.overlay-icon { font-size: 3rem; margin-bottom: 12px; }
|
||||
.overlay h2 {
|
||||
color: var(--phosphor);
|
||||
font-family: var(--font-mono);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.overlay p { color: var(--text-dim); }
|
||||
.save-hint {
|
||||
display: inline-block;
|
||||
margin-top: 16px;
|
||||
padding: 6px 16px;
|
||||
border-radius: 12px;
|
||||
background: var(--phosphor-burn);
|
||||
color: var(--phosphor-dim);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.error-box {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
top: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 4px;
|
||||
background: #331111;
|
||||
border: 1px solid #cc3333;
|
||||
color: #ff6666;
|
||||
border-radius: var(--radius);
|
||||
z-index: 10;
|
||||
}
|
||||
.unsupported-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,185 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/svelte";
|
||||
import GameDetail from "../GameDetail.svelte";
|
||||
|
||||
// Mock api.js
|
||||
vi.mock("../../lib/api.js", () => ({
|
||||
getGame: vi.fn(),
|
||||
deleteGame: vi.fn(),
|
||||
updateGame: vi.fn(),
|
||||
searchIGDB: vi.fn(),
|
||||
scrapeIGDB: vi.fn(),
|
||||
downloadGame: vi.fn(),
|
||||
bundleUrl: vi.fn(),
|
||||
setupBundleUrl: vi.fn(),
|
||||
artworkUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock router
|
||||
vi.mock("../../lib/router.js", () => ({
|
||||
push: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as api from "../../lib/api.js";
|
||||
|
||||
function makeGame(overrides = {}) {
|
||||
return {
|
||||
id: "game-1",
|
||||
title: "Commander Keen",
|
||||
year: 1991,
|
||||
genre: "Platformer",
|
||||
developer: "id Software",
|
||||
publisher: "Apogee",
|
||||
description: "A classic DOS platformer.",
|
||||
platform: "dos",
|
||||
ready: true,
|
||||
has_cover: false,
|
||||
has_setup: false,
|
||||
bundle_file: "commander-keen.zip",
|
||||
bundle_size: 512000,
|
||||
executables: ["keen.exe"],
|
||||
executable: null,
|
||||
videos: [],
|
||||
screenshots: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("GameDetail", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows loading state initially", () => {
|
||||
api.getGame.mockReturnValue(new Promise(() => {}));
|
||||
render(GameDetail, { props: { id: "game-1" } });
|
||||
expect(screen.getByText("Loading...")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows error when game is not found", async () => {
|
||||
api.getGame.mockRejectedValue(new Error("Game not found"));
|
||||
render(GameDetail, { props: { id: "missing" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("Game not found")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders game title", async () => {
|
||||
api.getGame.mockResolvedValue(makeGame());
|
||||
render(GameDetail, { props: { id: "game-1" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("Commander Keen")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders developer and publisher", async () => {
|
||||
api.getGame.mockResolvedValue(
|
||||
makeGame({ developer: "id Software", publisher: "Apogee" })
|
||||
);
|
||||
render(GameDetail, { props: { id: "game-1" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText(/id Software/)).toBeTruthy();
|
||||
expect(screen.getByText(/Apogee/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders year and genre as links", async () => {
|
||||
api.getGame.mockResolvedValue(
|
||||
makeGame({ year: 1991, genre: "Platformer" })
|
||||
);
|
||||
render(GameDetail, { props: { id: "game-1" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("1991").closest("a")).toBeTruthy();
|
||||
expect(screen.getByText("Platformer").closest("a")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows DOS badge for dos platform", async () => {
|
||||
api.getGame.mockResolvedValue(makeGame({ platform: "dos" }));
|
||||
render(GameDetail, { props: { id: "game-1" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("💾 DOS")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows Windows badge for windows platform", async () => {
|
||||
api.getGame.mockResolvedValue(makeGame({ platform: "windows" }));
|
||||
render(GameDetail, { props: { id: "game-1" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText(/Windows 3\.1/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows Play button for ready DOS games", async () => {
|
||||
api.getGame.mockResolvedValue(
|
||||
makeGame({ ready: true, platform: "dos" })
|
||||
);
|
||||
render(GameDetail, { props: { id: "game-1" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("▶ Play")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows disabled Play button for Windows games", async () => {
|
||||
api.getGame.mockResolvedValue(
|
||||
makeGame({ ready: true, platform: "windows" })
|
||||
);
|
||||
render(GameDetail, { props: { id: "game-1" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("▶ Unplayable")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows Setup button when has_setup is true", async () => {
|
||||
api.getGame.mockResolvedValue(
|
||||
makeGame({ has_setup: true })
|
||||
);
|
||||
render(GameDetail, { props: { id: "game-1" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("🛠 Setup")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows Edit, Download, Delete buttons", async () => {
|
||||
api.getGame.mockResolvedValue(makeGame());
|
||||
render(GameDetail, { props: { id: "game-1" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("✏ Edit")).toBeTruthy();
|
||||
expect(screen.getByText("⬇ Download")).toBeTruthy();
|
||||
expect(screen.getByText("✕ Delete")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("enters edit mode when Edit button is clicked", async () => {
|
||||
api.getGame.mockResolvedValue(makeGame());
|
||||
render(GameDetail, { props: { id: "game-1" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("✏ Edit")).toBeTruthy();
|
||||
});
|
||||
screen.getByText("✏ Edit").click();
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("Save")).toBeTruthy();
|
||||
expect(screen.getByText("Cancel")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows cover placeholder when no cover", async () => {
|
||||
api.getGame.mockResolvedValue(makeGame({ has_cover: false }));
|
||||
render(GameDetail, { props: { id: "game-1" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("💾")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows description when provided", async () => {
|
||||
api.getGame.mockResolvedValue(
|
||||
makeGame({ description: "A classic DOS platformer." })
|
||||
);
|
||||
render(GameDetail, { props: { id: "game-1" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("A classic DOS platformer.")
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/svelte";
|
||||
import Library from "../Library.svelte";
|
||||
|
||||
// Mock the API module that Library imports
|
||||
vi.mock("../../lib/api.js", () => ({
|
||||
listGames: vi.fn(),
|
||||
uploadGame: vi.fn(),
|
||||
searchIGDB: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as api from "../../lib/api.js";
|
||||
|
||||
function makeGame(overrides = {}) {
|
||||
return {
|
||||
id: "g1",
|
||||
title: "Test Game",
|
||||
year: 1995,
|
||||
genre: "Adventure",
|
||||
platform: "dos",
|
||||
has_cover: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Library", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.location.hash = "#/";
|
||||
});
|
||||
|
||||
it("shows loading state initially", () => {
|
||||
api.listGames.mockReturnValue(new Promise(() => {})); // never resolves
|
||||
render(Library);
|
||||
expect(screen.getByText("Scanning library...")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows empty state when no games", async () => {
|
||||
api.listGames.mockResolvedValue({ games: [] });
|
||||
render(Library);
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("No games yet")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders game cards when games load", async () => {
|
||||
api.listGames.mockResolvedValue({
|
||||
games: [
|
||||
makeGame({ id: "g1", title: "Game One" }),
|
||||
makeGame({ id: "g2", title: "Game Two" }),
|
||||
],
|
||||
});
|
||||
render(Library);
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("Game One")).toBeTruthy();
|
||||
expect(screen.getByText("Game Two")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows game count", async () => {
|
||||
api.listGames.mockResolvedValue({
|
||||
games: [
|
||||
makeGame({ id: "g1", title: "One" }),
|
||||
makeGame({ id: "g2", title: "Two" }),
|
||||
],
|
||||
});
|
||||
render(Library);
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("2 games")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows singular game count for one game", async () => {
|
||||
api.listGames.mockResolvedValue({
|
||||
games: [makeGame({ id: "g1", title: "Only" })],
|
||||
});
|
||||
render(Library);
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("1 game")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("filters", () => {
|
||||
it("shows filter options derived from loaded games", async () => {
|
||||
api.listGames.mockResolvedValue({
|
||||
games: [
|
||||
makeGame({ id: "g1", genre: "RPG", year: 1997 }),
|
||||
makeGame({ id: "g2", genre: "RPG", year: 1998 }),
|
||||
makeGame({ id: "g3", genre: "Adventure", year: 1995 }),
|
||||
],
|
||||
});
|
||||
render(Library);
|
||||
await vi.waitFor(() => {
|
||||
// Year labels appear both as filter options and in game cards
|
||||
expect(screen.getAllByText("1995").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText("1997").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText("1998").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("applies genre filter from props", async () => {
|
||||
api.listGames.mockResolvedValue({
|
||||
games: [
|
||||
makeGame({ id: "g1", title: "RPG Game", genre: "RPG" }),
|
||||
makeGame({ id: "g2", title: "Adventure Game", genre: "Adventure" }),
|
||||
],
|
||||
});
|
||||
render(Library, { props: { genreFilter: "RPG" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("RPG Game")).toBeTruthy();
|
||||
expect(screen.queryByText("Adventure Game")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("applies year filter from props", async () => {
|
||||
api.listGames.mockResolvedValue({
|
||||
games: [
|
||||
makeGame({ id: "g1", title: "Old Game", year: 1993 }),
|
||||
makeGame({ id: "g2", title: "New Game", year: 1997 }),
|
||||
],
|
||||
});
|
||||
render(Library, { props: { yearFilter: "1997" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("New Game")).toBeTruthy();
|
||||
expect(screen.queryByText("Old Game")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows filter-empty message when no filter options exist", async () => {
|
||||
api.listGames.mockResolvedValue({
|
||||
games: [
|
||||
makeGame({ id: "g1", genre: null, year: null }),
|
||||
makeGame({ id: "g2", genre: null, year: null }),
|
||||
],
|
||||
});
|
||||
render(Library);
|
||||
await vi.waitFor(() => {
|
||||
const dashes = screen.getAllByText("—");
|
||||
expect(dashes.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/svelte";
|
||||
import Play from "../Play.svelte";
|
||||
|
||||
// Mock the API module that Play imports
|
||||
vi.mock("../../lib/api.js", () => ({
|
||||
getGame: vi.fn(),
|
||||
bundleUrl: vi.fn(),
|
||||
setupBundleUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock router
|
||||
vi.mock("../../lib/router.js", () => ({
|
||||
push: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as api from "../../lib/api.js";
|
||||
|
||||
function makeGame(overrides = {}) {
|
||||
return {
|
||||
id: "game-1",
|
||||
title: "Commander Keen",
|
||||
platform: "dos",
|
||||
ready: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Play", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows loading state initially", () => {
|
||||
api.getGame.mockReturnValue(new Promise(() => {}));
|
||||
render(Play, { props: { id: "game-1" } });
|
||||
expect(screen.getByText("Loading game data...")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows not-ready state when game is not processed", async () => {
|
||||
api.getGame.mockResolvedValue(makeGame({ ready: false }));
|
||||
render(Play, { props: { id: "game-1" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("Not ready")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows unsupported warning for Windows games", async () => {
|
||||
api.getGame.mockResolvedValue(
|
||||
makeGame({ platform: "windows", ready: true })
|
||||
);
|
||||
render(Play, { props: { id: "game-1" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("Windows game")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error box when game fetch fails", async () => {
|
||||
api.getGame.mockRejectedValue(new Error("Game not found"));
|
||||
render(Play, { props: { id: "missing" } });
|
||||
// The error should appear in the overlay as well
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("⚠️")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows emulator booting state for ready DOS games", async () => {
|
||||
api.getGame.mockResolvedValue(
|
||||
makeGame({ platform: "dos", ready: true })
|
||||
);
|
||||
api.bundleUrl.mockReturnValue("/games/game-1.jsdos");
|
||||
render(Play, { props: { id: "game-1" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("Starting emulator...")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
compilerOptions: {
|
||||
runes: true,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
/// <reference types="vitest" />
|
||||
import { defineConfig } from "vite";
|
||||
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": "http://localhost:8765",
|
||||
"/games": "http://localhost:8765",
|
||||
"/artwork": "http://localhost:8765",
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
resolve: {
|
||||
conditions: process.env.VITEST ? ["browser", "module", "import"] : [],
|
||||
},
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
include: ["src/**/*.test.js"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user