Enrich program cards and serve local library

This commit is contained in:
Christian Johannesen 2026-10-07 21:10:17 +02:00
parent 8ed968c61b
commit 4fdbae2c8a
9 changed files with 155 additions and 51 deletions

2
.env
View file

@ -1,4 +1,2 @@
HOST_PORT=8134 HOST_PORT=8134
PROGRAM_REPOSITORY=https://git.p20.dev/christian/c64-basic
PROGRAM_BRANCH=main

View file

@ -3,8 +3,10 @@ FROM node:22-alpine
WORKDIR /app WORKDIR /app
COPY server.mjs ./server.mjs COPY server.mjs ./server.mjs
COPY public ./public COPY public ./public
COPY . /programs
ENV PORT=8080 ENV PORT=8080
ENV PROGRAM_DIRECTORY=/programs
EXPOSE 8080 EXPOSE 8080
USER node USER node

View file

@ -10,15 +10,15 @@ machine-language payload is required.
| Program | Type | Controls | Description | Run locally | | Program | Type | Controls | Description | Run locally |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| [Meteor Dodge](meteor-dodge/meteor-dodge.bas) | Arcade | `A`, `D`, `Q` | Dodge falling meteors and build your score. | [Run program](http://localhost:8134/?program=meteor-dodge%2Fmeteor-dodge.prg) | | [Meteor Dodge](meteor-dodge/meteor-dodge.bas) | Arcade | `A`, `D`, `Q` | Dodge falling meteors and build your score. | [Run program](http://localhost:8134/?program=meteor-dodge%2Fmeteor-dodge.prg) |
| [Code Breaker](code-breaker/code-breaker.bas) | Logic | Digits, `Q` | Deduce a four-digit secret in ten guesses. | [Run program](http://localhost:8134/?program=code-breaker%2Fcode-breaker.prg) | | [Code Breaker](code-breaker/code-breaker.bas) | Logic | `0`-`9`, `Q` | Deduce a four-digit secret in ten guesses. | [Run program](http://localhost:8134/?program=code-breaker%2Fcode-breaker.prg) |
| [Submarine Hunt](submarine-hunt/submarine-hunt.bas) | Strategy | Coordinates, `Q` | Find three hidden submarines with limited sonar shots. | [Run program](http://localhost:8134/?program=submarine-hunt%2Fsubmarine-hunt.prg) | | [Submarine Hunt](submarine-hunt/submarine-hunt.bas) | Strategy | Row, Column, `Q` | Find three hidden submarines with limited sonar shots. | [Run program](http://localhost:8134/?program=submarine-hunt%2Fsubmarine-hunt.prg) |
| [Lunar Lander](lunar-lander/lunar-lander.bas) | Simulation | `0`-`9`, `Q` | Choose engine burns and touch down gently. | [Run program](http://localhost:8134/?program=lunar-lander%2Flunar-lander.prg) | | [Lunar Lander](lunar-lander/lunar-lander.bas) | Simulation | `0`-`9`, `Q` | Choose engine burns and touch down gently. | [Run program](http://localhost:8134/?program=lunar-lander%2Flunar-lander.prg) |
| [Memory Matrix](memory-matrix/memory-matrix.bas) | Memory | `1`-`4`, `Q` | Repeat a growing sequence of flashing quadrants. | [Run program](http://localhost:8134/?program=memory-matrix%2Fmemory-matrix.prg) | | [Memory Matrix](memory-matrix/memory-matrix.bas) | Memory | `1`, `2`, `3`, `4`, `Q` | Repeat a growing sequence of flashing quadrants. | [Run program](http://localhost:8134/?program=memory-matrix%2Fmemory-matrix.prg) |
| [Robot Maze](robot-maze/robot-maze.bas) | Puzzle | `W`, `A`, `S`, `D`, `Q` | Navigate a freshly generated maze to its exit. | [Run program](http://localhost:8134/?program=robot-maze%2Frobot-maze.prg) | | [Robot Maze](robot-maze/robot-maze.bas) | Puzzle | `W`, `A`, `S`, `D`, `Q` | Navigate a freshly generated maze to its exit. | [Run program](http://localhost:8134/?program=robot-maze%2Frobot-maze.prg) |
| [Music Box](music-box/music-box.bas) | Music | `A`-`K`, `1`-`3`, `Q` | Play the SID chip as a small computer keyboard. | [Run program](http://localhost:8134/?program=music-box%2Fmusic-box.prg) | | [Music Box](music-box/music-box.bas) | Music | `A`-`K`, `1`, `2`, `3`, `Q` | Play the SID chip as a small computer keyboard. | [Run program](http://localhost:8134/?program=music-box%2Fmusic-box.prg) |
| [Pixel Painter](pixel-painter/pixel-painter.bas) | Creative | Cursor keys, Space, `C`, `Q` | Draw character-cell pictures on the screen. | [Run program](http://localhost:8134/?program=pixel-painter%2Fpixel-painter.prg) | | [Pixel Painter](pixel-painter/pixel-painter.bas) | Creative | Cursor keys, Space, `C`, `Q` | Draw character-cell pictures on the screen. | [Run program](http://localhost:8134/?program=pixel-painter%2Fpixel-painter.prg) |
| [Stock Trader](stock-trader/stock-trader.bas) | Simulation | `B`, `S`, `W`, `Q` | Trade a volatile stock over twenty market days. | [Run program](http://localhost:8134/?program=stock-trader%2Fstock-trader.prg) | | [Stock Trader](stock-trader/stock-trader.bas) | Simulation | `B`, `S`, `W`, `Q` | Trade a volatile stock over twenty market days. | [Run program](http://localhost:8134/?program=stock-trader%2Fstock-trader.prg) |
| [Starfield](starfield/starfield.bas) | Demo | `1`-`3`, `Q` | Watch and control a layered animated starfield. | [Run program](http://localhost:8134/?program=starfield%2Fstarfield.prg) | | [Starfield](starfield/starfield.bas) | Demo | `1`, `2`, `3`, `Q` | Watch and control a layered animated starfield. | [Run program](http://localhost:8134/?program=starfield%2Fstarfield.prg) |
## Running the programs ## Running the programs
@ -35,10 +35,12 @@ restrictions. Click inside the emulator to enable keyboard input and sound.
Direct keyboard input is enabled by default, mouse locking is disabled, and Direct keyboard input is enabled by default, mouse locking is disabled, and
moving the pointer anywhere over the emulator reveals its control bar. moving the pointer anywhere over the emulator reveals its control bar.
The app reads the public repository tree at runtime. New `.prg` files pushed to Docker Compose mounts this repository read-only at `/programs`, and the app
the configured branch appear after clicking **Refresh list**; rebuilding the serves PRG files from that local mount. There is no runtime dependency on the
Docker image is not required. Repository, branch, and host port settings live in Git server and no browser CORS boundary. Add a program to `programs.json` and
`.env`, where the default host port is `8134`. place its PRG in the referenced path; it appears after clicking **Refresh list**
without rebuilding the image. The default host port is configured as `8134` in
`.env`.
Each directory contains an editable, line-numbered `.bas` listing and a matching Each directory contains an editable, line-numbered `.bas` listing and a matching
tokenized `.prg` file. The PRGs use the normal C64 BASIC load address, `$0801`. tokenized `.prg` file. The PRGs use the normal C64 BASIC load address, `$0801`.

View file

@ -4,7 +4,8 @@ services:
ports: ports:
- "${HOST_PORT:-8134}:8080" - "${HOST_PORT:-8134}:8080"
environment: environment:
PROGRAM_REPOSITORY: "${PROGRAM_REPOSITORY:-https://git.p20.dev/christian/c64-basic}" PROGRAM_DIRECTORY: /programs
PROGRAM_BRANCH: "${PROGRAM_BRANCH:-main}" volumes:
- .:/programs:ro
restart: unless-stopped restart: unless-stopped

73
programs.json Normal file
View file

@ -0,0 +1,73 @@
[
{
"title": "Meteor Dodge",
"type": "Arcade",
"description": "Dodge falling meteors and build your score.",
"controls": ["A", "D", "Q"],
"path": "meteor-dodge/meteor-dodge.prg"
},
{
"title": "Code Breaker",
"type": "Logic",
"description": "Deduce a four-digit secret in ten guesses.",
"controls": ["0-9", "Q"],
"path": "code-breaker/code-breaker.prg"
},
{
"title": "Submarine Hunt",
"type": "Strategy",
"description": "Find three hidden submarines with limited sonar shots.",
"controls": ["ROW", "COLUMN", "Q"],
"path": "submarine-hunt/submarine-hunt.prg"
},
{
"title": "Lunar Lander",
"type": "Simulation",
"description": "Choose engine burns and touch down gently.",
"controls": ["0-9", "Q"],
"path": "lunar-lander/lunar-lander.prg"
},
{
"title": "Memory Matrix",
"type": "Memory",
"description": "Repeat a growing sequence of flashing quadrants.",
"controls": ["1", "2", "3", "4", "Q"],
"path": "memory-matrix/memory-matrix.prg"
},
{
"title": "Robot Maze",
"type": "Puzzle",
"description": "Navigate a freshly generated maze to its exit.",
"controls": ["W", "A", "S", "D", "Q"],
"path": "robot-maze/robot-maze.prg"
},
{
"title": "Music Box",
"type": "Music",
"description": "Play the SID chip as a small computer keyboard.",
"controls": ["A-K", "1", "2", "3", "Q"],
"path": "music-box/music-box.prg"
},
{
"title": "Pixel Painter",
"type": "Creative",
"description": "Draw character-cell pictures on the screen.",
"controls": ["CURSOR KEYS", "SPACE", "C", "Q"],
"path": "pixel-painter/pixel-painter.prg"
},
{
"title": "Stock Trader",
"type": "Simulation",
"description": "Trade a volatile stock over twenty market days.",
"controls": ["B", "S", "W", "Q"],
"path": "stock-trader/stock-trader.prg"
},
{
"title": "Starfield",
"type": "Demo",
"description": "Watch and control a layered animated starfield.",
"controls": ["1", "2", "3", "Q"],
"path": "starfield/starfield.prg"
}
]

View file

@ -9,6 +9,18 @@ function titleFromPath(path) {
return filename.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' '); return filename.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');
} }
const typeIcons = {
Arcade: '🎮',
Logic: '◆',
Strategy: '♟',
Simulation: '⚙',
Memory: '◫',
Puzzle: '⌘',
Music: '♪',
Creative: '✎',
Demo: '✦'
};
async function loadPrograms() { async function loadPrograms() {
status.hidden = false; status.hidden = false;
status.className = 'status'; status.className = 'status';
@ -23,11 +35,32 @@ async function loadPrograms() {
const link = document.createElement('a'); const link = document.createElement('a');
link.className = 'program-card'; link.className = 'program-card';
link.href = `/?program=${encodeURIComponent(program.path)}`; link.href = `/?program=${encodeURIComponent(program.path)}`;
const pill = document.createElement('span');
pill.className = 'type-pill';
pill.textContent = `${typeIcons[program.type] || '●'} ${program.type}`;
const title = document.createElement('strong'); const title = document.createElement('strong');
title.textContent = titleFromPath(program.path); title.className = 'program-title';
title.textContent = program.title;
const description = document.createElement('span');
description.className = 'program-description';
description.textContent = program.description;
const controls = document.createElement('span');
controls.className = 'controls';
controls.setAttribute('aria-label', `Controls: ${program.controls.join(', ')}`);
for (const control of program.controls) {
const key = document.createElement('kbd');
key.textContent = control;
controls.append(key);
}
const action = document.createElement('span'); const action = document.createElement('span');
action.className = 'program-action';
action.textContent = 'Run program →'; action.textContent = 'Run program →';
link.append(title, action); link.append(pill, title, description, controls, action);
programsElement.append(link); programsElement.append(link);
} }
status.textContent = programs.length ? `${programs.length} programs available` : 'No PRG files found.'; status.textContent = programs.length ? `${programs.length} programs available` : 'No PRG files found.';

View file

@ -16,7 +16,7 @@
<section id="library" class="panel"> <section id="library" class="panel">
<p class="eyebrow">Program library</p> <p class="eyebrow">Program library</p>
<h1>Choose a program</h1> <h1>Choose a program</h1>
<p class="lede">Programs are read directly from the public repository each time this page loads.</p> <p class="lede">Programs are read directly from the locally mounted repository each time this page loads.</p>
<p id="status" class="status">Loading programs…</p> <p id="status" class="status">Loading programs…</p>
<div id="programs" class="program-grid" aria-live="polite"></div> <div id="programs" class="program-grid" aria-live="polite"></div>
<button id="refresh" class="secondary" type="button">Refresh list</button> <button id="refresh" class="secondary" type="button">Refresh list</button>

View file

@ -16,9 +16,14 @@ h1 { margin: 0; font-size: clamp(2rem, 7vw, 4rem); line-height: 1; }
.lede, .hint { color: #c8c1df; } .lede, .hint { color: #c8c1df; }
.status { margin: 2rem 0 1rem; color: #9ce6c2; } .status { margin: 2rem 0 1rem; color: #9ce6c2; }
.program-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 1rem; } .program-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 1rem; }
.program-card { display: flex; min-height: 125px; flex-direction: column; justify-content: space-between; padding: 1.25rem; border: 1px solid #ffffff24; border-radius: 14px; background: #211c3bcc; color: #fff; text-decoration: none; transition: transform .15s, border-color .15s; } .program-card { display: flex; min-height: 250px; flex-direction: column; align-items: flex-start; padding: 1.25rem; border: 1px solid #ffffff24; border-radius: 14px; background: #211c3bcc; color: #fff; text-decoration: none; transition: transform .15s, border-color .15s; }
.program-card:hover { transform: translateY(-3px); border-color: #b58cff; } .program-card:hover { transform: translateY(-3px); border-color: #b58cff; }
.program-card span { color: #bca8f1; font-size: .9rem; } .type-pill { display: inline-flex; gap: .4rem; align-items: center; padding: .3rem .65rem; border: 1px solid #b58cff55; border-radius: 999px; background: #8d5cff24; color: #d9c7ff; font-size: .75rem; font-weight: 750; letter-spacing: .04em; text-transform: uppercase; }
.program-title { margin-top: 1rem; font-size: 1.2rem; }
.program-description { margin-top: .55rem; color: #c8c1df; font-size: .92rem; line-height: 1.45; }
.controls { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: 1rem; }
kbd { min-width: 1.8rem; padding: .25rem .45rem; border: 1px solid #aaa1c9; border-bottom-width: 3px; border-radius: 5px; background: #f2eff8; color: #241f38; font: 700 .72rem ui-monospace, monospace; text-align: center; }
.program-action { margin-top: auto; padding-top: 1.25rem; color: #bca8f1; font-size: .9rem; }
.button, button { display: inline-block; margin-top: 1.5rem; padding: .7rem 1rem; border: 1px solid #ffffff2b; border-radius: 8px; background: #302653; color: #fff; font: inherit; text-decoration: none; cursor: pointer; } .button, button { display: inline-block; margin-top: 1.5rem; padding: .7rem 1rem; border: 1px solid #ffffff2b; border-radius: 8px; background: #302653; color: #fff; font: inherit; text-decoration: none; cursor: pointer; }
button:disabled { opacity: .5; cursor: wait; } button:disabled { opacity: .5; cursor: wait; }
.player-heading { display: flex; align-items: end; justify-content: space-between; gap: 1rem; } .player-heading { display: flex; align-items: end; justify-content: space-between; gap: 1rem; }

View file

@ -1,20 +1,12 @@
import { createReadStream, existsSync, statSync } from 'node:fs'; import { createReadStream, existsSync, readFileSync, statSync } from 'node:fs';
import { createServer } from 'node:http'; import { createServer } from 'node:http';
import { extname, join, normalize } from 'node:path'; import { extname, join, normalize, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
const port = Number(process.env.PORT || 8080); const port = Number(process.env.PORT || 8080);
const repository = (process.env.PROGRAM_REPOSITORY || 'https://git.p20.dev/christian/c64-basic').replace(/\/$/, ''); const programDirectory = resolve(process.env.PROGRAM_DIRECTORY || fileURLToPath(new URL('./', import.meta.url)));
const branch = process.env.PROGRAM_BRANCH || 'main'; const manifestPath = join(programDirectory, 'programs.json');
const publicDirectory = fileURLToPath(new URL('./public/', import.meta.url)); const publicDirectory = fileURLToPath(new URL('./public/', import.meta.url));
const repositoryMatch = repository.match(/^https:\/\/([^/]+)\/([^/]+)\/([^/]+)$/);
if (!repositoryMatch) {
throw new Error('PROGRAM_REPOSITORY must look like https://host/owner/repository');
}
const [, repositoryHost, repositoryOwner, repositoryName] = repositoryMatch;
const apiBase = `https://${repositoryHost}/api/v1/repos/${encodeURIComponent(repositoryOwner)}/${encodeURIComponent(repositoryName)}`;
function sendJson(response, status, value) { function sendJson(response, status, value) {
const body = JSON.stringify(value); const body = JSON.stringify(value);
@ -26,36 +18,34 @@ function sendJson(response, status, value) {
response.end(body); response.end(body);
} }
async function fetchPrograms() { function fetchPrograms() {
const treeUrl = `${apiBase}/git/trees/${encodeURIComponent(branch)}?recursive=true`; const programs = JSON.parse(readFileSync(manifestPath, 'utf8'));
const upstream = await fetch(treeUrl, { signal: AbortSignal.timeout(10000) }); if (!Array.isArray(programs)) throw new Error('programs.json must contain an array');
if (!upstream.ok) throw new Error(`Repository returned HTTP ${upstream.status}`); return programs.map(program => {
const tree = await upstream.json(); if (!program.path?.toLowerCase().endsWith('.prg')) throw new Error('Every program must reference a PRG file');
return tree.tree const filePath = resolve(programDirectory, program.path);
.filter(entry => entry.type === 'blob' && entry.path.toLowerCase().endsWith('.prg')) if (!filePath.startsWith(programDirectory + sep) || !existsSync(filePath) || !statSync(filePath).isFile()) {
.map(entry => ({ path: entry.path, size: entry.size })) throw new Error(`Program file is missing: ${program.path}`);
.sort((left, right) => left.path.localeCompare(right.path)); }
return { ...program, size: statSync(filePath).size };
});
} }
async function proxyProgram(response, programPath) { function sendProgram(response, programPath) {
const programs = await fetchPrograms(); const programs = fetchPrograms();
if (!programs.some(program => program.path === programPath)) { if (!programs.some(program => program.path === programPath)) {
sendJson(response, 404, { error: 'Program not found' }); sendJson(response, 404, { error: 'Program not found' });
return; return;
} }
const encodedPath = programPath.split('/').map(encodeURIComponent).join('/'); const filePath = resolve(programDirectory, programPath);
const rawUrl = `${repository}/raw/branch/${encodeURIComponent(branch)}/${encodedPath}`;
const upstream = await fetch(rawUrl, { signal: AbortSignal.timeout(10000) });
if (!upstream.ok) throw new Error(`Program download returned HTTP ${upstream.status}`);
const bytes = Buffer.from(await upstream.arrayBuffer());
response.writeHead(200, { response.writeHead(200, {
'Content-Type': 'application/octet-stream', 'Content-Type': 'application/octet-stream',
'Content-Length': bytes.length, 'Content-Length': statSync(filePath).size,
'Content-Disposition': `inline; filename="${programPath.split('/').at(-1)}"`, 'Content-Disposition': `inline; filename="${programPath.split('/').at(-1)}"`,
'Cache-Control': 'no-store' 'Cache-Control': 'no-store'
}); });
response.end(bytes); createReadStream(filePath).pipe(response);
} }
function serveStatic(response, pathname) { function serveStatic(response, pathname) {
@ -80,15 +70,15 @@ createServer(async (request, response) => {
if (request.method !== 'GET') { if (request.method !== 'GET') {
sendJson(response, 405, { error: 'Method not allowed' }); sendJson(response, 405, { error: 'Method not allowed' });
} else if (url.pathname === '/api/programs') { } else if (url.pathname === '/api/programs') {
sendJson(response, 200, { repository, branch, programs: await fetchPrograms() }); sendJson(response, 200, { programs: fetchPrograms() });
} else if (url.pathname === '/api/program') { } else if (url.pathname === '/api/program') {
await proxyProgram(response, url.searchParams.get('path') || ''); sendProgram(response, url.searchParams.get('path') || '');
} else if (!serveStatic(response, url.pathname)) { } else if (!serveStatic(response, url.pathname)) {
sendJson(response, 404, { error: 'Not found' }); sendJson(response, 404, { error: 'Not found' });
} }
} catch (error) { } catch (error) {
console.error(error); console.error(error);
if (!response.headersSent) sendJson(response, 502, { error: 'Unable to reach the program repository' }); if (!response.headersSent) sendJson(response, 500, { error: 'Unable to read the program library' });
else response.end(); else response.end();
} }
}).listen(port, '0.0.0.0', () => { }).listen(port, '0.0.0.0', () => {