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,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>
|
||||
Reference in New Issue
Block a user