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

Page not found

+ Back to Library +
+ {/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} + {game.title} + {: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 @@ + + +
+
+

Library

+
+ loadGames()} + /> + +
+
+ + {#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} +
+
+ ← {game.title} + {#if !emulatorReady} + + {/if} +
+ + {#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