Initial commit: Dostalgia — DOS game hub

- Go backend (single binary) with embedded Svelte frontend
- JSON-per-game storage (no database)
- .jsdos bundle creation on upload
- Dark phosphor-green theme (#33FF33)
- js-dos v8 emulator integration
- Sockdrive support for games >80MB
- IGDB placeholder for future artwork scraping
- Docker deployment ready
This commit is contained in:
David Alvarez
2026-05-23 11:18:41 +02:00
parent c3c154ec65
commit 1efd10f81f
24 changed files with 3314 additions and 2 deletions
+63
View File
@@ -0,0 +1,63 @@
<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);
function parseHash() {
const hash = window.location.hash.slice(1) || "/";
const parts = hash.split("/").filter(Boolean);
if (parts.length === 0 || (parts.length === 1 && parts[0] === "")) {
route = "/";
gameId = null;
} 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
$effect(() => {
parseHash();
const handler = () => parseHash();
window.addEventListener("hashchange", handler);
return () => window.removeEventListener("hashchange", handler);
});
</script>
<Header />
<main class="container">
{#if route === "/"}
<Library />
{: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>
+230
View File
@@ -0,0 +1,230 @@
/* ═══════════════════════════════════════════════════════════════
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: "JetBrains Mono", "Fira Code", "Cascadia Code", 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;
}
body {
font-family: var(--font-sans);
background: var(--bg);
color: var(--text);
min-height: 100vh;
line-height: 1.6;
}
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;
}
/* ═══════════════════════════════════════════════════════════════
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;
}
+108
View File
@@ -0,0 +1,108 @@
<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.ready}
<div class="badge">Ready</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;
}
.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;
}
</style>
+65
View File
@@ -0,0 +1,65 @@
<script>
import { push } from "./router.js";
</script>
<header class="header">
<div class="header-inner">
<a href="#/" class="logo" onclick={(e) => { e.preventDefault(); push("/"); }}>
<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={() => push("/")}>Library</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;
}
.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;
}
nav {
display: flex;
gap: 8px;
}
</style>
+81
View File
@@ -0,0 +1,81 @@
/** 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) {
const form = new FormData();
form.append("file", file);
if (title) form.append("title", title);
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 (placeholder) */
export async function searchIGDB(query) {
return request(`/api/igdb/search?query=${encodeURIComponent(query)}`);
}
/** 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 */
export function bundleUrl(game) {
if (!game) return null;
if (game.bundle_type === "sockdrive") {
return `/api/sockdrive/${game.slug}/file?path=.jsdos/dosbox.conf`;
}
return `/games/${game.slug}.jsdos`;
}
+9
View File
@@ -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);
}
+8
View File
@@ -0,0 +1,8 @@
import App from "./App.svelte";
import { mount } from "svelte";
const app = mount(App, {
target: document.getElementById("app"),
});
export default app;
+162
View File
@@ -0,0 +1,162 @@
<script>
import { getGame, deleteGame, updateGame, bundleUrl, 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("");
async function load() {
loading = true;
try {
game = await getGame(id);
} 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 || "";
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,
});
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}`);
}
$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 -->
<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}
</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-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.year || game.genre}
<div class="meta">
{#if game.year}<span class="meta-item">{game.year}</span>{/if}
{#if game.genre}<span class="meta-item">{game.genre}</span>{/if}
</div>
{/if}
{#if game.developer}
<p class="dev">by {game.developer}{game.publisher ? ` · ${game.publisher}` : ""}</p>
{/if}
{#if game.description}
<p class="description">{game.description}</p>
{/if}
<div class="actions">
{#if game.ready}
<button class="btn btn-primary" onclick={handlePlay}> Play</button>
{/if}
<button class="btn" onclick={startEdit}> Edit</button>
<button class="btn btn-danger" onclick={handleDelete}> Delete</button>
</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; }
@media (max-width: 640px) { .detail-layout { grid-template-columns: 1fr; } }
.cover-section { border-radius: var(--radius); overflow: hidden; }
.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; }
.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; }
.dev { color: var(--text-dim); margin-bottom: 16px; }
.description { color: var(--text); line-height: 1.7; margin-bottom: 24px; }
.actions { display: flex; gap: 8px; flex-wrap: wrap; }
.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; }
</style>
+165
View File
@@ -0,0 +1,165 @@
<script>
import { listGames, uploadGame } from "../lib/api.js";
import GameCard from "../lib/GameCard.svelte";
let games = $state([]);
let loading = $state(true);
let search = $state("");
let uploading = $state(false);
let uploadError = $state("");
let fileInput;
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;
}
async function handleUpload(e) {
const file = e.target.files?.[0];
if (!file) return;
uploading = true;
uploadError = "";
try {
await uploadGame(file);
await loadGames();
fileInput.value = "";
} catch (err) {
uploadError = err.message;
}
uploading = false;
}
$effect(() => {
loadGames();
});
</script>
<div class="library">
<div class="toolbar">
<h1>Library</h1>
<div class="toolbar-actions">
<input
type="text"
placeholder="Search games..."
bind:value={search}
oninput={() => loadGames()}
/>
<label class="btn btn-primary upload-label">
{uploading ? "Uploading..." : "+ Upload Game"}
<input
bind:this={fileInput}
type="file"
accept=".zip"
class="hidden-input"
onchange={handleUpload}
disabled={uploading}
/>
</label>
</div>
</div>
{#if uploadError}
<div class="error">{uploadError}</div>
{/if}
{#if loading}
<div class="loading">Scanning library...</div>
{:else if games.length === 0}
<div class="empty-state">
<div class="empty-icon">💾</div>
<h2>No games yet</h2>
<p>Upload a DOS game ZIP to get started</p>
<label class="btn btn-primary" style="margin-top: 16px;">
Upload your first game
<input type="file" accept=".zip" class="hidden-input" onchange={handleUpload} />
</label>
</div>
{:else}
<div class="grid">
{#each games as game (game.id)}
<GameCard {game} />
{/each}
</div>
<div class="count">{games.length} game{games.length !== 1 ? "s" : ""}</div>
{/if}
</div>
<style>
.library {
padding: 32px 0;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
flex-wrap: wrap;
gap: 16px;
}
.toolbar h1 {
color: var(--phosphor);
font-family: var(--font-mono);
animation: phosphor-pulse 3s ease-in-out infinite;
}
.toolbar-actions {
display: flex;
gap: 12px;
align-items: center;
}
.toolbar-actions input {
min-width: 200px;
}
.hidden-input {
display: none;
}
.upload-label {
cursor: pointer;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 20px;
}
.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;
}
</style>
+136
View File
@@ -0,0 +1,136 @@
<script>
import { getGame, bundleUrl } from "../lib/api.js";
import { push } from "../lib/router.js";
let { id } = $props();
let game = $state(null);
let loading = $state(true);
let error = $state("");
let emulatorReady = $state(false);
let dosContainer;
async function load() {
loading = true;
try {
game = await getGame(id);
} catch (e) {
error = "Game not found";
}
loading = false;
}
function startEmulator() {
if (!game || !game.ready) return;
// Load js-dos from CDN dynamically
const css = document.createElement("link");
css.rel = "stylesheet";
css.href = "https://v8.js-dos.com/latest/js-dos.css";
document.head.appendChild(css);
const script = document.createElement("script");
script.src = "https://v8.js-dos.com/latest/js-dos.js";
script.onload = () => {
const url = bundleUrl(game);
if (window.Dos && dosContainer && url) {
window.Dos(dosContainer, { url });
emulatorReady = true;
}
};
document.head.appendChild(script);
emulatorReady = true;
}
$effect(() => { load(); });
// Cleanup on unmount
$effect(() => {
return () => {
// js-dos handles its own cleanup
};
});
</script>
{#if loading}
<div class="loading">Loading...</div>
{:else if error}
<div class="error">{error}</div>
<a href="#/" class="back-link">← Back to Library</a>
{:else if game}
<div class="play-page">
<div class="play-header">
<a href={`#/game/${game.id}`} class="back-link">{game.title}</a>
{#if !emulatorReady}
<button class="btn btn-primary" onclick={startEmulator}>
▶ Launch Emulator
</button>
{/if}
</div>
{#if emulatorReady}
<div class="emulator-wrapper">
<div bind:this={dosContainer} class="dos-container"></div>
</div>
{:else}
<div class="launch-screen">
<div class="launch-icon">🕹️</div>
<h2>{game.title}</h2>
<p class="launch-info">
{game.year ? `${game.year} · ` : ""}{game.genre || ""}
</p>
<p class="launch-hint">Click "Launch Emulator" above to start playing</p>
<p class="save-hint">💾 Game saves are stored automatically in your browser</p>
</div>
{/if}
</div>
{/if}
<style>
.play-page { padding: 16px 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; color: var(--text-dim); }
.back-link:hover { color: var(--phosphor); }
.play-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.emulator-wrapper {
background: #000;
border-radius: var(--radius);
overflow: hidden;
border: 1px solid var(--border);
}
.dos-container {
width: 100%;
aspect-ratio: 4/3;
max-height: 80vh;
}
.dos-container :global(canvas) {
width: 100% !important;
height: 100% !important;
}
.launch-screen {
text-align: center;
padding: 80px 20px;
border: 1px dashed var(--border);
border-radius: var(--radius);
}
.launch-icon { font-size: 4rem; margin-bottom: 16px; }
.launch-screen h2 { color: var(--phosphor); font-family: var(--font-mono); margin-bottom: 8px; }
.launch-info { color: var(--text-dim); margin-bottom: 24px; }
.launch-hint { color: var(--text-dim); margin-bottom: 8px; }
.save-hint {
color: var(--phosphor-dim);
font-size: 0.85rem;
margin-top: 16px;
background: var(--phosphor-burn);
display: inline-block;
padding: 6px 16px;
border-radius: 12px;
}
</style>