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
+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>