diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..26948dc
--- /dev/null
+++ b/.gitignore
@@ -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/
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..0ad50e0
--- /dev/null
+++ b/Dockerfile
@@ -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"]
diff --git a/README.md b/README.md
index b60eadb..5de04a3 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,61 @@
-# dostalgia
+# Dostalgia
-A nostalgic DOS game hub — upload, scrape artwork, and play your classic games in the browser
\ No newline at end of file
+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
+}
+```
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..a83710e
--- /dev/null
+++ b/docker-compose.yml
@@ -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
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..1f85178
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Dostalgia
+
+
+
+
+
+
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
new file mode 100644
index 0000000..58f91d0
--- /dev/null
+++ b/frontend/package-lock.json
@@ -0,0 +1,1216 @@
+{
+ "name": "dostalgia-frontend",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "dostalgia-frontend",
+ "version": "0.1.0",
+ "devDependencies": {
+ "@sveltejs/vite-plugin-svelte": "^4.0.0",
+ "svelte": "^5.0.0",
+ "vite": "^5.0.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz",
+ "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz",
+ "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz",
+ "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz",
+ "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz",
+ "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz",
+ "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz",
+ "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz",
+ "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz",
+ "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz",
+ "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz",
+ "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz",
+ "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz",
+ "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz",
+ "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz",
+ "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz",
+ "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz",
+ "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz",
+ "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz",
+ "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz",
+ "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz",
+ "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz",
+ "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz",
+ "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz",
+ "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz",
+ "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@sveltejs/acorn-typescript": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz",
+ "integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==",
+ "dev": true,
+ "peerDependencies": {
+ "acorn": "^8.9.0"
+ }
+ },
+ "node_modules/@sveltejs/vite-plugin-svelte": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-4.0.4.tgz",
+ "integrity": "sha512-0ba1RQ/PHen5FGpdSrW7Y3fAMQjrXantECALeOiOdBdzR5+5vPP6HVZRLmZaQL+W8m++o+haIAKq5qT+MiZ7VA==",
+ "dev": true,
+ "dependencies": {
+ "@sveltejs/vite-plugin-svelte-inspector": "^3.0.0-next.0||^3.0.0",
+ "debug": "^4.3.7",
+ "deepmerge": "^4.3.1",
+ "kleur": "^4.1.5",
+ "magic-string": "^0.30.12",
+ "vitefu": "^1.0.3"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22"
+ },
+ "peerDependencies": {
+ "svelte": "^5.0.0-next.96 || ^5.0.0",
+ "vite": "^5.0.0"
+ }
+ },
+ "node_modules/@sveltejs/vite-plugin-svelte-inspector": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-3.0.1.tgz",
+ "integrity": "sha512-2CKypmj1sM4GE7HjllT7UKmo4Q6L5xFRd7VMGEWhYnZ+wc6AUVU01IBd7yUi6WnFndEwWoMNOd6e8UjoN0nbvQ==",
+ "dev": true,
+ "dependencies": {
+ "debug": "^4.3.7"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22"
+ },
+ "peerDependencies": {
+ "@sveltejs/vite-plugin-svelte": "^4.0.0-next.0||^4.0.0",
+ "svelte": "^5.0.0-next.96 || ^5.0.0",
+ "vite": "^5.0.0"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true
+ },
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "dev": true
+ },
+ "node_modules/acorn": {
+ "version": "8.16.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
+ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
+ "dev": true,
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/aria-query": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
+ "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/axobject-query": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
+ "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/deepmerge": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
+ "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/devalue": {
+ "version": "5.8.1",
+ "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz",
+ "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==",
+ "dev": true
+ },
+ "node_modules/esbuild": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.21.5",
+ "@esbuild/android-arm": "0.21.5",
+ "@esbuild/android-arm64": "0.21.5",
+ "@esbuild/android-x64": "0.21.5",
+ "@esbuild/darwin-arm64": "0.21.5",
+ "@esbuild/darwin-x64": "0.21.5",
+ "@esbuild/freebsd-arm64": "0.21.5",
+ "@esbuild/freebsd-x64": "0.21.5",
+ "@esbuild/linux-arm": "0.21.5",
+ "@esbuild/linux-arm64": "0.21.5",
+ "@esbuild/linux-ia32": "0.21.5",
+ "@esbuild/linux-loong64": "0.21.5",
+ "@esbuild/linux-mips64el": "0.21.5",
+ "@esbuild/linux-ppc64": "0.21.5",
+ "@esbuild/linux-riscv64": "0.21.5",
+ "@esbuild/linux-s390x": "0.21.5",
+ "@esbuild/linux-x64": "0.21.5",
+ "@esbuild/netbsd-x64": "0.21.5",
+ "@esbuild/openbsd-x64": "0.21.5",
+ "@esbuild/sunos-x64": "0.21.5",
+ "@esbuild/win32-arm64": "0.21.5",
+ "@esbuild/win32-ia32": "0.21.5",
+ "@esbuild/win32-x64": "0.21.5"
+ }
+ },
+ "node_modules/esm-env": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
+ "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
+ "dev": true
+ },
+ "node_modules/esrap": {
+ "version": "2.2.9",
+ "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.9.tgz",
+ "integrity": "sha512-4KijP+NxCWthMCUC3qHbE6n4vCjqgJS1uAYKhuT/GWfFTf1Qyive2TgOjep+gzbSzRfnNyaN/UU9YmdOt8Eg0A==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.4.15"
+ },
+ "peerDependencies": {
+ "@typescript-eslint/types": "^8.2.0"
+ },
+ "peerDependenciesMeta": {
+ "@typescript-eslint/types": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/is-reference": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
+ "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
+ "dev": true,
+ "dependencies": {
+ "@types/estree": "^1.0.6"
+ }
+ },
+ "node_modules/kleur": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
+ "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/locate-character": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
+ "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
+ "dev": true
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.12",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
+ "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true
+ },
+ "node_modules/postcss": {
+ "version": "8.5.15",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
+ "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "dependencies": {
+ "nanoid": "^3.3.12",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz",
+ "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==",
+ "dev": true,
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.60.4",
+ "@rollup/rollup-android-arm64": "4.60.4",
+ "@rollup/rollup-darwin-arm64": "4.60.4",
+ "@rollup/rollup-darwin-x64": "4.60.4",
+ "@rollup/rollup-freebsd-arm64": "4.60.4",
+ "@rollup/rollup-freebsd-x64": "4.60.4",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.60.4",
+ "@rollup/rollup-linux-arm-musleabihf": "4.60.4",
+ "@rollup/rollup-linux-arm64-gnu": "4.60.4",
+ "@rollup/rollup-linux-arm64-musl": "4.60.4",
+ "@rollup/rollup-linux-loong64-gnu": "4.60.4",
+ "@rollup/rollup-linux-loong64-musl": "4.60.4",
+ "@rollup/rollup-linux-ppc64-gnu": "4.60.4",
+ "@rollup/rollup-linux-ppc64-musl": "4.60.4",
+ "@rollup/rollup-linux-riscv64-gnu": "4.60.4",
+ "@rollup/rollup-linux-riscv64-musl": "4.60.4",
+ "@rollup/rollup-linux-s390x-gnu": "4.60.4",
+ "@rollup/rollup-linux-x64-gnu": "4.60.4",
+ "@rollup/rollup-linux-x64-musl": "4.60.4",
+ "@rollup/rollup-openbsd-x64": "4.60.4",
+ "@rollup/rollup-openharmony-arm64": "4.60.4",
+ "@rollup/rollup-win32-arm64-msvc": "4.60.4",
+ "@rollup/rollup-win32-ia32-msvc": "4.60.4",
+ "@rollup/rollup-win32-x64-gnu": "4.60.4",
+ "@rollup/rollup-win32-x64-msvc": "4.60.4",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/rollup/node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/svelte": {
+ "version": "5.55.9",
+ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.9.tgz",
+ "integrity": "sha512-fTjjT8cHLDwigcu2j3pv7Jq04LklXevPB8uBgyHNiTXv+RMNvVnrjS4UEYrLMkhuq1vpCodHjiW+z/95SDs/fg==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.4",
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@sveltejs/acorn-typescript": "^1.0.10",
+ "@types/estree": "^1.0.5",
+ "@types/trusted-types": "^2.0.7",
+ "acorn": "^8.12.1",
+ "aria-query": "5.3.1",
+ "axobject-query": "^4.1.0",
+ "clsx": "^2.1.1",
+ "devalue": "^5.8.1",
+ "esm-env": "^1.2.1",
+ "esrap": "^2.2.9",
+ "is-reference": "^3.0.3",
+ "locate-character": "^3.0.0",
+ "magic-string": "^0.30.11",
+ "zimmerframe": "^1.1.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite": {
+ "version": "5.4.21",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+ "dev": true,
+ "dependencies": {
+ "esbuild": "^0.21.3",
+ "postcss": "^8.4.43",
+ "rollup": "^4.20.0"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitefu": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz",
+ "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==",
+ "dev": true,
+ "peerDependencies": {
+ "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/zimmerframe": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
+ "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
+ "dev": true
+ }
+ }
+}
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..48a7728
--- /dev/null
+++ b/frontend/package.json
@@ -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"
+ }
+}
diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
new file mode 100644
index 0000000..998e7e7
--- /dev/null
+++ b/frontend/src/App.svelte
@@ -0,0 +1,63 @@
+
+
+
+
+
+ {#if route === "/"}
+
+ {:else if route === "/game" && gameId}
+
+ {:else if route === "/play" && gameId}
+
+ {:else}
+
+ {/if}
+
+
+
diff --git a/frontend/src/app.css b/frontend/src/app.css
new file mode 100644
index 0000000..0a57402
--- /dev/null
+++ b/frontend/src/app.css
@@ -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;
+}
diff --git a/frontend/src/lib/GameCard.svelte b/frontend/src/lib/GameCard.svelte
new file mode 100644
index 0000000..ec77ea3
--- /dev/null
+++ b/frontend/src/lib/GameCard.svelte
@@ -0,0 +1,108 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/lib/Header.svelte b/frontend/src/lib/Header.svelte
new file mode 100644
index 0000000..4a69f6a
--- /dev/null
+++ b/frontend/src/lib/Header.svelte
@@ -0,0 +1,65 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js
new file mode 100644
index 0000000..8812e3b
--- /dev/null
+++ b/frontend/src/lib/api.js
@@ -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`;
+}
diff --git a/frontend/src/lib/router.js b/frontend/src/lib/router.js
new file mode 100644
index 0000000..bc63d91
--- /dev/null
+++ b/frontend/src/lib/router.js
@@ -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);
+}
diff --git a/frontend/src/main.js b/frontend/src/main.js
new file mode 100644
index 0000000..88ff31f
--- /dev/null
+++ b/frontend/src/main.js
@@ -0,0 +1,8 @@
+import App from "./App.svelte";
+import { mount } from "svelte";
+
+const app = mount(App, {
+ target: document.getElementById("app"),
+});
+
+export default app;
diff --git a/frontend/src/pages/GameDetail.svelte b/frontend/src/pages/GameDetail.svelte
new file mode 100644
index 0000000..0e48923
--- /dev/null
+++ b/frontend/src/pages/GameDetail.svelte
@@ -0,0 +1,162 @@
+
+
+{#if loading}
+ Loading...
+{:else if error && !game}
+ {error}
+ ← Back to Library
+{:else if game}
+
+
← Back to Library
+
+
+
+
+ {#if game.has_cover}
+

+ {:else}
+
💾
+ {/if}
+
+
+
+
+ {#if editing}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {:else}
+
{game.title}
+ {#if game.year || game.genre}
+
+ {#if game.year}{game.year}{/if}
+ {#if game.genre}{game.genre}{/if}
+
+ {/if}
+ {#if game.developer}
+
by {game.developer}{game.publisher ? ` · ${game.publisher}` : ""}
+ {/if}
+ {#if game.description}
+
{game.description}
+ {/if}
+
+
+ {#if game.ready}
+
+ {/if}
+
+
+
+ {/if}
+
+
+
+{/if}
+
+
\ No newline at end of file
diff --git a/frontend/src/pages/Library.svelte b/frontend/src/pages/Library.svelte
new file mode 100644
index 0000000..f68f5ea
--- /dev/null
+++ b/frontend/src/pages/Library.svelte
@@ -0,0 +1,165 @@
+
+
+
+
+
+ {#if uploadError}
+
{uploadError}
+ {/if}
+
+ {#if loading}
+
Scanning library...
+ {:else if games.length === 0}
+
+
💾
+
No games yet
+
Upload a DOS game ZIP to get started
+
+
+ {:else}
+
+ {#each games as game (game.id)}
+
+ {/each}
+
+
{games.length} game{games.length !== 1 ? "s" : ""}
+ {/if}
+
+
+
\ No newline at end of file
diff --git a/frontend/src/pages/Play.svelte b/frontend/src/pages/Play.svelte
new file mode 100644
index 0000000..32db238
--- /dev/null
+++ b/frontend/src/pages/Play.svelte
@@ -0,0 +1,136 @@
+
+
+{#if loading}
+ Loading...
+{:else if error}
+ {error}
+ ← Back to Library
+{:else if game}
+
+
+
+ {#if emulatorReady}
+
+ {:else}
+
+
🕹️
+
{game.title}
+
+ {game.year ? `${game.year} · ` : ""}{game.genre || ""}
+
+
Click "Launch Emulator" above to start playing
+
💾 Game saves are stored automatically in your browser
+
+ {/if}
+
+{/if}
+
+
\ No newline at end of file
diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js
new file mode 100644
index 0000000..1c8d7f1
--- /dev/null
+++ b/frontend/svelte.config.js
@@ -0,0 +1,5 @@
+export default {
+ compilerOptions: {
+ runes: true,
+ },
+};
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
new file mode 100644
index 0000000..30d9380
--- /dev/null
+++ b/frontend/vite.config.js
@@ -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,
+ },
+});
diff --git a/game.go b/game.go
new file mode 100644
index 0000000..9ad0fad
--- /dev/null
+++ b/game.go
@@ -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)
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..aa177a2
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,3 @@
+module dostalgia
+
+go 1.23.9
diff --git a/igdb.go b/igdb.go
new file mode 100644
index 0000000..6204a49
--- /dev/null
+++ b/igdb.go
@@ -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,
+ },
+ })
+}
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..06b726c
--- /dev/null
+++ b/main.go
@@ -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)
+}
diff --git a/upload.go b/upload.go
new file mode 100644
index 0000000..95953b3
--- /dev/null
+++ b/upload.go
@@ -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