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:
+19
@@ -0,0 +1,19 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
frontend/node_modules/
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
data/games/*
|
||||||
|
data/artwork/*
|
||||||
|
data/saves/*
|
||||||
|
!data/games/.gitkeep
|
||||||
|
!data/artwork/.gitkeep
|
||||||
|
!data/saves/.gitkeep
|
||||||
|
|
||||||
|
# Binary
|
||||||
|
dostalgia
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
# Dostalgia — Dockerized DOS game hub
|
||||||
|
|
||||||
|
# ─── Build frontend ───────────────────────────────
|
||||||
|
FROM node:20-alpine AS frontend-builder
|
||||||
|
WORKDIR /src
|
||||||
|
COPY frontend/package.json frontend/package-lock.json* ./
|
||||||
|
RUN npm ci 2>/dev/null || npm install
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ─── Build Go binary ─────────────────────────────
|
||||||
|
FROM golang:1.23-alpine AS go-builder
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download 2>/dev/null || true
|
||||||
|
COPY *.go ./
|
||||||
|
COPY --from=frontend-builder /src/dist ./frontend/dist/
|
||||||
|
RUN CGO_ENABLED=0 go build -o /dostalgia .
|
||||||
|
|
||||||
|
# ─── Runtime ─────────────────────────────────────
|
||||||
|
FROM alpine:3.20
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata
|
||||||
|
COPY --from=go-builder /dostalgia /dostalgia
|
||||||
|
VOLUME /data
|
||||||
|
EXPOSE 8765
|
||||||
|
ENV DOSTALGIA_DATA=/data
|
||||||
|
ENV PORT=8765
|
||||||
|
ENTRYPOINT ["/dostalgia"]
|
||||||
@@ -1,3 +1,61 @@
|
|||||||
# dostalgia
|
# Dostalgia
|
||||||
|
|
||||||
A nostalgic DOS game hub — upload, scrape artwork, and play your classic games in the browser
|
A nostalgic DOS game hub — upload, scrape artwork, and play classic DOS games directly in the browser via js-dos.
|
||||||
|
|
||||||
|
## Quick Start (Docker)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t dostalgia .
|
||||||
|
docker run -p 8765:8765 -v $(pwd)/data:/data dostalgia
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Terminal 1 — Go API:
|
||||||
|
```bash
|
||||||
|
DOSTALGIA_DEV=1 go run .
|
||||||
|
```
|
||||||
|
|
||||||
|
Terminal 2 — Frontend dev server:
|
||||||
|
```bash
|
||||||
|
cd frontend && npm install && npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open http://localhost:5173
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **Backend**: Single Go binary. No database — each game is a directory with a `game.json` file.
|
||||||
|
- **Frontend**: Svelte SPA, embedded in the Go binary at build time.
|
||||||
|
- **Emulation**: js-dos v8 loaded from CDN, runs DOSBox in WebAssembly.
|
||||||
|
- **Storage**: `/data/games/{id}/` — contains `game.json`, `.jsdos` bundle, and cover art.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/api/games` | List all games |
|
||||||
|
| GET | `/api/games/:id` | Get game details |
|
||||||
|
| PATCH | `/api/games/:id` | Update game metadata |
|
||||||
|
| DELETE | `/api/games/:id` | Delete a game |
|
||||||
|
| POST | `/api/upload` | Upload a game ZIP (multipart) |
|
||||||
|
| POST | `/api/games/:id/cover` | Upload cover art |
|
||||||
|
| GET | `/api/igdb/search?q=` | Search IGDB (placeholder) |
|
||||||
|
|
||||||
|
## Game JSON Schema
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "doom",
|
||||||
|
"title": "Doom",
|
||||||
|
"year": 1993,
|
||||||
|
"genre": "FPS",
|
||||||
|
"developer": "id Software",
|
||||||
|
"publisher": "id Software",
|
||||||
|
"description": "The iconic first-person shooter...",
|
||||||
|
"bundle_type": "standard",
|
||||||
|
"bundle_file": "doom.jsdos",
|
||||||
|
"has_cover": true,
|
||||||
|
"ready": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
services:
|
||||||
|
dostalgia:
|
||||||
|
build: .
|
||||||
|
container_name: dostalgia
|
||||||
|
ports:
|
||||||
|
- "8765:8765"
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
|
environment:
|
||||||
|
- DOSTALGIA_DATA=/data
|
||||||
|
- PORT=8765
|
||||||
|
# IGDB credentials (optional):
|
||||||
|
# - TWITCH_CLIENT_ID=your_client_id
|
||||||
|
# - TWITCH_CLIENT_SECRET=your_client_secret
|
||||||
|
restart: unless-stopped
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Game represents the metadata for a single DOS game.
|
||||||
|
// Stored as game.json in data/games/{id}/
|
||||||
|
type Game struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Year int `json:"year,omitempty"`
|
||||||
|
Genre string `json:"genre,omitempty"`
|
||||||
|
Developer string `json:"developer,omitempty"`
|
||||||
|
Publisher string `json:"publisher,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Rating float64 `json:"rating,omitempty"`
|
||||||
|
Platforms []string `json:"platforms,omitempty"`
|
||||||
|
|
||||||
|
// Bundle info
|
||||||
|
BundleType string `json:"bundle_type,omitempty"` // "standard" or "sockdrive"
|
||||||
|
BundleFile string `json:"bundle_file,omitempty"` // path relative to game dir
|
||||||
|
|
||||||
|
// IGDB
|
||||||
|
IGDBID int `json:"igdb_id,omitempty"`
|
||||||
|
IGDBCoverID string `json:"igdb_cover_id,omitempty"`
|
||||||
|
|
||||||
|
// Local file hints (cover exists if cover.jpg/png/webp is present)
|
||||||
|
HasCover bool `json:"has_cover"`
|
||||||
|
HasScreenshot bool `json:"has_screenshots"`
|
||||||
|
Screenshots []string `json:"screenshots,omitempty"`
|
||||||
|
|
||||||
|
Ready bool `json:"ready"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// gameDir returns the directory for a game by ID.
|
||||||
|
func gameDir(id string) string {
|
||||||
|
return filepath.Join(dataDir, "games", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// gameJSONPath returns the path to a game's game.json.
|
||||||
|
func gameJSONPath(id string) string {
|
||||||
|
return filepath.Join(gameDir(id), "game.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadGame reads a game.json file and returns the Game.
|
||||||
|
func loadGame(id string) (*Game, error) {
|
||||||
|
path := gameJSONPath(id)
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
g := &Game{}
|
||||||
|
if err := json.Unmarshal(data, g); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect if cover file exists
|
||||||
|
g.HasCover = fileExists(filepath.Join(gameDir(id), "cover.jpg")) ||
|
||||||
|
fileExists(filepath.Join(gameDir(id), "cover.png")) ||
|
||||||
|
fileExists(filepath.Join(gameDir(id), "cover.webp"))
|
||||||
|
|
||||||
|
return g, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveGame writes a game's metadata to its game.json.
|
||||||
|
func saveGame(g *Game) error {
|
||||||
|
g.UpdatedAt = time.Now()
|
||||||
|
if g.CreatedAt.IsZero() {
|
||||||
|
g.CreatedAt = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := gameDir(g.ID)
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(g, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(gameJSONPath(g.ID), data, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// listGames scans the games directory and returns all games, sorted by title.
|
||||||
|
func listGames() ([]*Game, error) {
|
||||||
|
gamesDir := filepath.Join(dataDir, "games")
|
||||||
|
entries, err := os.ReadDir(gamesDir)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return []*Game{}, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var games []*Game
|
||||||
|
if len(entries) > 0 {
|
||||||
|
games = make([]*Game, 0, len(entries))
|
||||||
|
} else {
|
||||||
|
games = make([]*Game, 0)
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
g, err := loadGame(e.Name())
|
||||||
|
if err != nil {
|
||||||
|
continue // skip broken entries
|
||||||
|
}
|
||||||
|
games = append(games, g)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(games, func(i, j int) bool {
|
||||||
|
return games[i].Title < games[j].Title
|
||||||
|
})
|
||||||
|
|
||||||
|
return games, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileExists checks if a file exists.
|
||||||
|
func fileExists(path string) bool {
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizeID cleans a string for use as a game directory name.
|
||||||
|
func sanitizeID(s string) string {
|
||||||
|
result := make([]byte, 0, len(s))
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
c := s[i]
|
||||||
|
if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') {
|
||||||
|
result = append(result, c)
|
||||||
|
} else if c >= 'A' && c <= 'Z' {
|
||||||
|
result = append(result, c+32) // lowercase
|
||||||
|
} else if c == ' ' || c == '-' || c == '_' {
|
||||||
|
result = append(result, '-')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Trim leading/trailing hyphens
|
||||||
|
for len(result) > 0 && result[0] == '-' {
|
||||||
|
result = result[1:]
|
||||||
|
}
|
||||||
|
for len(result) > 0 && result[len(result)-1] == '-' {
|
||||||
|
result = result[:len(result)-1]
|
||||||
|
}
|
||||||
|
if len(result) == 0 {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
return string(result)
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
// handleIGDBSearch handles GET /api/igdb/search — placeholder.
|
||||||
|
// Once IGDB credentials are configured, this will query the Twitch API.
|
||||||
|
func handleIGDBSearch(w http.ResponseWriter, r *http.Request) {
|
||||||
|
query := r.URL.Query().Get("q")
|
||||||
|
if query == "" {
|
||||||
|
http.Error(w, "Query parameter 'q' required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Implement real IGDB search.
|
||||||
|
// Requires TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET environment variables.
|
||||||
|
//
|
||||||
|
// Flow:
|
||||||
|
// 1. POST https://id.twitch.tv/oauth2/token for access token
|
||||||
|
// 2. POST https://api.igdb.com/v4/games with body: search "{query}"; fields name,cover.*,genres.*,screenshots.*;
|
||||||
|
// 3. Resolve cover URLs → https://images.igdb.com/igdb/image/upload/t_cover_big/{image_id}.jpg
|
||||||
|
// 4. Return matches
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, []map[string]string{
|
||||||
|
{
|
||||||
|
"message": "IGDB integration not yet configured. Set TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET env vars.",
|
||||||
|
"query": query,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"encoding/json"
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed all:frontend/dist
|
||||||
|
var frontendFS embed.FS
|
||||||
|
|
||||||
|
var (
|
||||||
|
dataDir string
|
||||||
|
devMode bool
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Determine data directory
|
||||||
|
dataDir = os.Getenv("DOSTALGIA_DATA")
|
||||||
|
if dataDir == "" {
|
||||||
|
dataDir = "data"
|
||||||
|
}
|
||||||
|
devMode = os.Getenv("DOSTALGIA_DEV") == "1"
|
||||||
|
|
||||||
|
// Ensure data directories exist
|
||||||
|
for _, d := range []string{"games", "artwork", "saves"} {
|
||||||
|
os.MkdirAll(filepath.Join(dataDir, d), 0755)
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
// API routes
|
||||||
|
mux.HandleFunc("/api/health", handleHealth)
|
||||||
|
mux.HandleFunc("/api/games", handleGames)
|
||||||
|
mux.HandleFunc("/api/games/", handleGameByID)
|
||||||
|
mux.HandleFunc("/api/upload", handleUploadGame)
|
||||||
|
mux.HandleFunc("/api/igdb/search", handleIGDBSearch)
|
||||||
|
|
||||||
|
// Serve game bundles and artwork as static files
|
||||||
|
gameFS := http.FileServer(http.Dir(filepath.Join(dataDir, "games")))
|
||||||
|
mux.Handle("/games/", http.StripPrefix("/games/", gameFS))
|
||||||
|
|
||||||
|
artFS := http.FileServer(http.Dir(filepath.Join(dataDir, "artwork")))
|
||||||
|
mux.Handle("/artwork/", http.StripPrefix("/artwork/", artFS))
|
||||||
|
|
||||||
|
// Sockdrive file server
|
||||||
|
mux.HandleFunc("/api/sockdrive/", handleSockdrive)
|
||||||
|
|
||||||
|
// Frontend SPA — serve embedded files, fall back to index.html
|
||||||
|
mux.HandleFunc("/", handleFrontend)
|
||||||
|
|
||||||
|
port := os.Getenv("PORT")
|
||||||
|
if port == "" {
|
||||||
|
port = "8765"
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("🎮 Dostalgia starting on :%s (data: %s)", port, dataDir)
|
||||||
|
if devMode {
|
||||||
|
log.Println("⚠️ DEV mode — run Vite dev server alongside for hot reload")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := http.ListenAndServe(":"+port, mux); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── API Handlers ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{
|
||||||
|
"status": "ok",
|
||||||
|
"version": "0.1.0",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleGames(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
games, err := listGames()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"games": games,
|
||||||
|
"total": len(games),
|
||||||
|
})
|
||||||
|
|
||||||
|
case http.MethodPost:
|
||||||
|
// Create a game manually (without upload)
|
||||||
|
var g Game
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&g); err != nil {
|
||||||
|
http.Error(w, "Invalid JSON", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if g.Title == "" {
|
||||||
|
http.Error(w, "Title is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if g.ID == "" {
|
||||||
|
g.ID = sanitizeID(g.Title)
|
||||||
|
}
|
||||||
|
if err := saveGame(&g); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, g)
|
||||||
|
|
||||||
|
default:
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleGameByID(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Extract ID from /api/games/{id} or /api/games/{id}/...
|
||||||
|
path := strings.TrimPrefix(r.URL.Path, "/api/games/")
|
||||||
|
parts := strings.SplitN(path, "/", 2)
|
||||||
|
id := parts[0]
|
||||||
|
|
||||||
|
if id == "" {
|
||||||
|
http.Error(w, "Game ID required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cover upload sub-endpoint
|
||||||
|
if len(parts) > 1 && parts[1] == "cover" {
|
||||||
|
handleUploadCover(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
g, err := loadGame(id)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Game not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, g)
|
||||||
|
|
||||||
|
case http.MethodPatch:
|
||||||
|
g, err := loadGame(id)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Game not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var updates map[string]interface{}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
|
||||||
|
http.Error(w, "Invalid JSON", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Apply simple string/number fields
|
||||||
|
if v, ok := updates["title"].(string); ok {
|
||||||
|
g.Title = v
|
||||||
|
}
|
||||||
|
if v, ok := updates["year"].(float64); ok {
|
||||||
|
g.Year = int(v)
|
||||||
|
}
|
||||||
|
if v, ok := updates["genre"].(string); ok {
|
||||||
|
g.Genre = v
|
||||||
|
}
|
||||||
|
if v, ok := updates["developer"].(string); ok {
|
||||||
|
g.Developer = v
|
||||||
|
}
|
||||||
|
if v, ok := updates["publisher"].(string); ok {
|
||||||
|
g.Publisher = v
|
||||||
|
}
|
||||||
|
if v, ok := updates["description"].(string); ok {
|
||||||
|
g.Description = v
|
||||||
|
}
|
||||||
|
if v, ok := updates["rating"].(float64); ok {
|
||||||
|
g.Rating = v
|
||||||
|
}
|
||||||
|
if err := saveGame(g); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, g)
|
||||||
|
|
||||||
|
case http.MethodDelete:
|
||||||
|
g, err := loadGame(id)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Game not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Remove the entire game directory
|
||||||
|
if err := os.RemoveAll(gameDir(g.ID)); err != nil {
|
||||||
|
http.Error(w, "Failed to delete game", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||||
|
|
||||||
|
default:
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Sockdrive File Server ──────────────────────────────────────
|
||||||
|
|
||||||
|
func handleSockdrive(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// /api/sockdrive/{slug}/file?path=...
|
||||||
|
path := strings.TrimPrefix(r.URL.Path, "/api/sockdrive/")
|
||||||
|
parts := strings.SplitN(path, "/", 2)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
http.Error(w, "Invalid path", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slug := parts[0]
|
||||||
|
|
||||||
|
if parts[1] == "file" {
|
||||||
|
filePath := r.URL.Query().Get("path")
|
||||||
|
if filePath == "" {
|
||||||
|
http.Error(w, "path query parameter required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fullPath := filepath.Join(gameDir(slug), filePath)
|
||||||
|
// Prevent traversal
|
||||||
|
fullPath = filepath.Clean(fullPath)
|
||||||
|
if !strings.HasPrefix(fullPath, filepath.Clean(gameDir(slug))) {
|
||||||
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.ServeFile(w, r, fullPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if parts[1] == "files" {
|
||||||
|
// List all files in game directory
|
||||||
|
var files []map[string]interface{}
|
||||||
|
gameDirPath := gameDir(slug)
|
||||||
|
filepath.WalkDir(gameDirPath, func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil || d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
info, _ := d.Info()
|
||||||
|
rel, _ := filepath.Rel(gameDirPath, path)
|
||||||
|
files = append(files, map[string]interface{}{
|
||||||
|
"path": strings.ReplaceAll(rel, "\\", "/"),
|
||||||
|
"size": info.Size(),
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"files": files,
|
||||||
|
"total": len(files),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Error(w, "Not found", http.StatusNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Frontend SPA ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
func handleFrontend(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Don't intercept API or static routes
|
||||||
|
if strings.HasPrefix(r.URL.Path, "/api/") ||
|
||||||
|
strings.HasPrefix(r.URL.Path, "/games/") ||
|
||||||
|
strings.HasPrefix(r.URL.Path, "/artwork/") {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var content fs.FS
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if devMode {
|
||||||
|
content = os.DirFS("frontend/dist")
|
||||||
|
} else {
|
||||||
|
content, err = fs.Sub(frontendFS, "frontend/dist")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Frontend not built", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to serve the exact file
|
||||||
|
filePath := strings.TrimPrefix(r.URL.Path, "/")
|
||||||
|
if filePath == "" {
|
||||||
|
filePath = "index.html"
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := content.Open(filePath)
|
||||||
|
if err == nil {
|
||||||
|
f.Close()
|
||||||
|
http.FileServer(http.FS(content)).ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to index.html for SPA routing
|
||||||
|
indexData, err := fs.ReadFile(content, "index.html")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Frontend not built. Run 'cd frontend && npm run build'", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Write(indexData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
json.NewEncoder(w).Encode(data)
|
||||||
|
}
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const dosboxConfTemplate = `[autoexec]
|
||||||
|
@echo off
|
||||||
|
mount c .
|
||||||
|
c:
|
||||||
|
cls
|
||||||
|
%s
|
||||||
|
`
|
||||||
|
|
||||||
|
// handleUploadGame handles POST /api/upload — receives a ZIP, creates a .jsdos bundle.
|
||||||
|
func handleUploadGame(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse multipart form (max 500MB)
|
||||||
|
if err := r.ParseMultipartForm(500 << 20); err != nil {
|
||||||
|
http.Error(w, "Failed to parse upload: "+err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, header, err := r.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "No file provided", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
title := r.FormValue("title")
|
||||||
|
if title == "" {
|
||||||
|
title = strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename))
|
||||||
|
}
|
||||||
|
|
||||||
|
gameID := sanitizeID(title)
|
||||||
|
|
||||||
|
// Ensure uniqueness
|
||||||
|
baseID := gameID
|
||||||
|
counter := 2
|
||||||
|
for {
|
||||||
|
if _, err := loadGame(gameID); err != nil {
|
||||||
|
break // not found, we can use this ID
|
||||||
|
}
|
||||||
|
gameID = fmt.Sprintf("%s-%d", baseID, counter)
|
||||||
|
counter++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save uploaded ZIP to temp file
|
||||||
|
tmpDir, err := os.MkdirTemp("", "dostalgia-upload-")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
zipPath := filepath.Join(tmpDir, header.Filename)
|
||||||
|
dst, err := os.Create(zipPath)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(dst, file); err != nil {
|
||||||
|
dst.Close()
|
||||||
|
http.Error(w, "Failed to save upload", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dst.Close()
|
||||||
|
|
||||||
|
// Extract ZIP
|
||||||
|
extractDir := filepath.Join(tmpDir, "extracted")
|
||||||
|
if err := os.MkdirAll(extractDir, 0755); err != nil {
|
||||||
|
http.Error(w, "Server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := extractZip(zipPath, extractDir); err != nil {
|
||||||
|
http.Error(w, "Invalid ZIP file: "+err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find main executable
|
||||||
|
mainExe := findMainExe(extractDir)
|
||||||
|
if mainExe == "" {
|
||||||
|
http.Error(w, "No executable (.exe/.com/.bat) found in the archive", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine size and strategy
|
||||||
|
sizeMB := dirSizeMB(extractDir)
|
||||||
|
bundleType := "standard"
|
||||||
|
if sizeMB > 80 {
|
||||||
|
bundleType = "sockdrive"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up game directory
|
||||||
|
gameDirPath := gameDir(gameID)
|
||||||
|
if err := os.MkdirAll(gameDirPath, 0755); err != nil {
|
||||||
|
http.Error(w, "Server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var bundleFile string
|
||||||
|
if bundleType == "sockdrive" {
|
||||||
|
// Copy all files loose
|
||||||
|
if err := copyDir(extractDir, gameDirPath); err != nil {
|
||||||
|
http.Error(w, "Failed to store game files", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Create dosbox.conf
|
||||||
|
confDir := filepath.Join(gameDirPath, ".jsdos")
|
||||||
|
os.MkdirAll(confDir, 0755)
|
||||||
|
relExe, _ := filepath.Rel(extractDir, mainExe)
|
||||||
|
os.WriteFile(filepath.Join(confDir, "dosbox.conf"),
|
||||||
|
[]byte(fmt.Sprintf(dosboxConfTemplate, relExe)), 0644)
|
||||||
|
bundleFile = ".jsdos/dosbox.conf"
|
||||||
|
} else {
|
||||||
|
// Create .jsdos ZIP bundle
|
||||||
|
bundleFile = gameID + ".jsdos"
|
||||||
|
bundlePath := filepath.Join(gameDirPath, bundleFile)
|
||||||
|
if err := createJSDOSBundle(extractDir, mainExe, bundlePath); err != nil {
|
||||||
|
http.Error(w, "Failed to create bundle: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save metadata
|
||||||
|
game := &Game{
|
||||||
|
ID: gameID,
|
||||||
|
Title: title,
|
||||||
|
BundleType: bundleType,
|
||||||
|
BundleFile: bundleFile,
|
||||||
|
Ready: true,
|
||||||
|
}
|
||||||
|
if err := saveGame(game); err != nil {
|
||||||
|
http.Error(w, "Failed to save metadata", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, game)
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractZip extracts a ZIP file to the given directory.
|
||||||
|
func extractZip(zipPath, dest string) error {
|
||||||
|
r, err := zip.OpenReader(zipPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
|
||||||
|
for _, f := range r.File {
|
||||||
|
// Prevent path traversal
|
||||||
|
target := filepath.Join(dest, f.Name)
|
||||||
|
if !strings.HasPrefix(filepath.Clean(target), filepath.Clean(dest)+string(os.PathSeparator)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if f.FileInfo().IsDir() {
|
||||||
|
os.MkdirAll(target, 0755)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
os.MkdirAll(filepath.Dir(target), 0755)
|
||||||
|
src, err := f.Open()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dst, err := os.Create(target)
|
||||||
|
if err != nil {
|
||||||
|
src.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = io.Copy(dst, src)
|
||||||
|
src.Close()
|
||||||
|
dst.Close()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mainExeCandidate is used for sorting executables by relevance.
|
||||||
|
type mainExeCandidate struct {
|
||||||
|
depth int
|
||||||
|
isInstaller bool
|
||||||
|
size int64
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
|
||||||
|
// findMainExe finds the most likely main executable in an extracted game directory.
|
||||||
|
func findMainExe(dir string) string {
|
||||||
|
var candidates []mainExeCandidate
|
||||||
|
|
||||||
|
filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil || d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ext := strings.ToLower(filepath.Ext(d.Name()))
|
||||||
|
if ext != ".exe" && ext != ".com" && ext != ".bat" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
info, _ := d.Info()
|
||||||
|
name := strings.ToLower(d.Name())
|
||||||
|
isInstaller := strings.Contains(name, "install") ||
|
||||||
|
strings.Contains(name, "setup") ||
|
||||||
|
strings.Contains(name, "config")
|
||||||
|
depth := strings.Count(path[len(dir):], string(os.PathSeparator))
|
||||||
|
candidates = append(candidates, mainExeCandidate{
|
||||||
|
depth: depth,
|
||||||
|
isInstaller: isInstaller,
|
||||||
|
size: info.Size(),
|
||||||
|
name: name,
|
||||||
|
path: path,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort: shallow depth, non-installer, larger files first
|
||||||
|
sortCandidates(candidates)
|
||||||
|
return candidates[0].path
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortCandidates(c []mainExeCandidate) {
|
||||||
|
for i := 0; i < len(c); i++ {
|
||||||
|
for j := i + 1; j < len(c); j++ {
|
||||||
|
// Compare: depth (lower first), installer (false first), size (larger first)
|
||||||
|
swap := false
|
||||||
|
if c[i].depth != c[j].depth {
|
||||||
|
swap = c[i].depth > c[j].depth
|
||||||
|
} else if c[i].isInstaller != c[j].isInstaller {
|
||||||
|
swap = c[i].isInstaller
|
||||||
|
} else {
|
||||||
|
swap = c[i].size < c[j].size
|
||||||
|
}
|
||||||
|
if swap {
|
||||||
|
c[i], c[j] = c[j], c[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dirSizeMB returns the total size of a directory in megabytes.
|
||||||
|
func dirSizeMB(dir string) float64 {
|
||||||
|
var total int64
|
||||||
|
filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil || d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
info, _ := d.Info()
|
||||||
|
total += info.Size()
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return float64(total) / (1024 * 1024)
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyDir copies a directory recursively.
|
||||||
|
func copyDir(src, dst string) error {
|
||||||
|
return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rel, _ := filepath.Rel(src, path)
|
||||||
|
target := filepath.Join(dst, rel)
|
||||||
|
if d.IsDir() {
|
||||||
|
return os.MkdirAll(target, 0755)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(target, data, 0644)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// createJSDOSBundle creates a .jsdos bundle ZIP from extracted game files.
|
||||||
|
func createJSDOSBundle(extractDir, mainExe, bundlePath string) error {
|
||||||
|
// Create a temp directory for bundling
|
||||||
|
tmpDir, err := os.MkdirTemp("", "dostalgia-bundle-")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
// Copy game files
|
||||||
|
if err := copyDir(extractDir, tmpDir); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create .jsdos config
|
||||||
|
jsdosDir := filepath.Join(tmpDir, ".jsdos")
|
||||||
|
if err := os.MkdirAll(jsdosDir, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
relExe, _ := filepath.Rel(extractDir, mainExe)
|
||||||
|
conf := fmt.Sprintf(dosboxConfTemplate, relExe)
|
||||||
|
if err := os.WriteFile(filepath.Join(jsdosDir, "dosbox.conf"), []byte(conf), 0644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the ZIP
|
||||||
|
zf, err := os.Create(bundlePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer zf.Close()
|
||||||
|
|
||||||
|
zw := zip.NewWriter(zf)
|
||||||
|
defer zw.Close()
|
||||||
|
|
||||||
|
return filepath.WalkDir(tmpDir, func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil || d.IsDir() {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rel, _ := filepath.Rel(tmpDir, path)
|
||||||
|
w, err := zw.Create(rel)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = w.Write(data)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUploadCover handles POST /api/games/{id}/cover — upload cover art.
|
||||||
|
func handleUploadCover(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id := r.PathValue("id")
|
||||||
|
if _, err := loadGame(id); err != nil {
|
||||||
|
http.Error(w, "Game not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.ParseMultipartForm(10 << 20); err != nil {
|
||||||
|
http.Error(w, "File too large", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, header, err := r.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "No file provided", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// Determine extension
|
||||||
|
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||||
|
if ext != ".jpg" && ext != ".jpeg" && ext != ".png" && ext != ".webp" {
|
||||||
|
http.Error(w, "Only JPG, PNG, or WebP images accepted", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
coverPath := filepath.Join(gameDir(id), "cover"+ext)
|
||||||
|
// Remove old cover files
|
||||||
|
for _, oldExt := range []string{".jpg", ".jpeg", ".png", ".webp"} {
|
||||||
|
os.Remove(filepath.Join(gameDir(id), "cover"+oldExt))
|
||||||
|
}
|
||||||
|
|
||||||
|
dst, err := os.Create(coverPath)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer dst.Close()
|
||||||
|
|
||||||
|
if _, err := io.Copy(dst, file); err != nil {
|
||||||
|
http.Error(w, "Failed to save cover", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "path": coverPath})
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = time.Now // keep import
|
||||||
Reference in New Issue
Block a user