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:
@@ -0,0 +1,13 @@
|
||||
<!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>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1216
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "dostalgia-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"svelte": "^5.0.0",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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,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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
compilerOptions: {
|
||||
runes: true,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user