Add library tools and BASIC source modal

This commit is contained in:
Christian Johannesen 2026-10-07 21:22:58 +02:00
parent 4fdbae2c8a
commit e13835ab27
4 changed files with 206 additions and 44 deletions

View file

@ -3,6 +3,16 @@ const player = document.querySelector('#player');
const status = document.querySelector('#status');
const programsElement = document.querySelector('#programs');
const refreshButton = document.querySelector('#refresh');
const searchInput = document.querySelector('#search');
const typeFilter = document.querySelector('#type-filter');
const sortOrder = document.querySelector('#sort-order');
const gridViewButton = document.querySelector('#grid-view');
const listViewButton = document.querySelector('#list-view');
const sourceModal = document.querySelector('#source-modal');
const sourceTitle = document.querySelector('#source-title');
const sourceStatus = document.querySelector('#source-status');
const sourceCode = document.querySelector('#source-code');
let allPrograms = [];
function titleFromPath(path) {
const filename = path.split('/').at(-1).replace(/\.prg$/i, '');
@ -21,49 +31,97 @@ const typeIcons = {
Demo: '✦'
};
function renderPrograms() {
const query = searchInput.value.trim().toLowerCase();
const selectedType = typeFilter.value;
const programs = allPrograms
.filter(program => !selectedType || program.type === selectedType)
.filter(program => !query || `${program.title} ${program.description} ${program.type} ${program.controls.join(' ')}`.toLowerCase().includes(query))
.sort((left, right) => {
if (sortOrder.value === 'type') return left.type.localeCompare(right.type) || left.title.localeCompare(right.title);
return left.title.localeCompare(right.title);
});
programsElement.replaceChildren();
for (const program of programs) {
const card = document.createElement('article');
card.className = 'program-card';
const pill = document.createElement('span');
pill.className = 'type-pill';
pill.textContent = `${typeIcons[program.type] || '●'} ${program.type}`;
const title = document.createElement('h2');
title.className = 'program-title';
title.textContent = program.title;
const description = document.createElement('p');
description.className = 'program-description';
description.textContent = program.description;
const controls = document.createElement('div');
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 actions = document.createElement('div');
actions.className = 'program-actions';
const run = document.createElement('a');
run.className = 'program-action primary-action';
run.href = `/?program=${encodeURIComponent(program.path)}`;
run.textContent = 'Run program →';
const source = document.createElement('button');
source.className = 'source-action';
source.type = 'button';
source.textContent = 'Show source';
source.disabled = !program.source;
source.addEventListener('click', () => showSource(program));
actions.append(run, source);
card.append(pill, title, description, controls, actions);
programsElement.append(card);
}
status.textContent = `${programs.length} of ${allPrograms.length} programs shown`;
}
async function showSource(program) {
sourceTitle.textContent = program.title;
sourceStatus.hidden = false;
sourceStatus.className = 'status';
sourceStatus.textContent = 'Loading source…';
sourceCode.hidden = true;
sourceCode.querySelector('code').textContent = '';
sourceModal.showModal();
try {
const response = await fetch(`/api/source?path=${encodeURIComponent(program.path)}`, { cache: 'no-store' });
if (!response.ok) throw new Error('The program source could not be loaded.');
sourceCode.querySelector('code').textContent = await response.text();
sourceStatus.hidden = true;
sourceCode.hidden = false;
} catch (error) {
sourceStatus.className = 'status error';
sourceStatus.textContent = error.message;
}
}
async function loadPrograms() {
status.hidden = false;
status.className = 'status';
status.textContent = 'Loading programs…';
programsElement.replaceChildren();
refreshButton.disabled = true;
try {
const response = await fetch('/api/programs', { cache: 'no-store' });
if (!response.ok) throw new Error('The program list could not be loaded.');
const { programs } = await response.json();
for (const program of programs) {
const link = document.createElement('a');
link.className = 'program-card';
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');
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');
action.className = 'program-action';
action.textContent = 'Run program →';
link.append(pill, title, description, controls, action);
programsElement.append(link);
}
status.textContent = programs.length ? `${programs.length} programs available` : 'No PRG files found.';
allPrograms = programs;
const currentType = typeFilter.value;
const types = [...new Set(programs.map(program => program.type))].sort();
typeFilter.replaceChildren(new Option('All types', ''), ...types.map(type => new Option(type, type)));
if (types.includes(currentType)) typeFilter.value = currentType;
renderPrograms();
} catch (error) {
status.className = 'status error';
status.textContent = error.message;
@ -103,6 +161,27 @@ function startEmulator(programPath) {
}
refreshButton.addEventListener('click', loadPrograms);
searchInput.addEventListener('input', renderPrograms);
typeFilter.addEventListener('change', renderPrograms);
sortOrder.addEventListener('change', renderPrograms);
gridViewButton.addEventListener('click', () => setView('grid'));
listViewButton.addEventListener('click', () => setView('list'));
document.querySelector('#close-source').addEventListener('click', () => sourceModal.close());
sourceModal.addEventListener('click', event => {
if (event.target === sourceModal) sourceModal.close();
});
function setView(view) {
const list = view === 'list';
programsElement.classList.toggle('list-view', list);
gridViewButton.classList.toggle('active', !list);
listViewButton.classList.toggle('active', list);
gridViewButton.setAttribute('aria-pressed', String(!list));
listViewButton.setAttribute('aria-pressed', String(list));
localStorage.setItem('program-view', view);
}
setView(localStorage.getItem('program-view') === 'list' ? 'list' : 'grid');
const programPath = new URLSearchParams(location.search).get('program');
if (programPath) startEmulator(programPath);
else loadPrograms();

View file

@ -15,11 +15,32 @@
<main>
<section id="library" class="panel">
<p class="eyebrow">Program library</p>
<h1>Choose a program</h1>
<p class="lede">Programs are read directly from the locally mounted repository each time this page loads.</p>
<h1>Small programs. Pure 8-bit fun.</h1>
<p class="lede">Explore a growing collection of games, puzzles, creative tools, and audiovisual experiments written in Commodore 64 BASIC. Pick something that catches your eye, run it instantly, or open the source to see how every line works.</p>
<div class="library-toolbar" aria-label="Program library controls">
<label class="search-field">
<span class="sr-only">Search programs</span>
<input id="search" type="search" placeholder="Search programs…" autocomplete="off">
</label>
<label>
<span class="sr-only">Filter by type</span>
<select id="type-filter"><option value="">All types</option></select>
</label>
<label>
<span class="sr-only">Sort programs</span>
<select id="sort-order">
<option value="title">Sort: Title</option>
<option value="type">Sort: Type</option>
</select>
</label>
<div class="view-switcher" role="group" aria-label="View style">
<button id="grid-view" class="view-button active" type="button" aria-pressed="true" title="Grid view">▦</button>
<button id="list-view" class="view-button" type="button" aria-pressed="false" title="List view">☷</button>
</div>
<button id="refresh" class="toolbar-button" type="button">Refresh</button>
</div>
<p id="status" class="status">Loading programs…</p>
<div id="programs" class="program-grid" aria-live="polite"></div>
<button id="refresh" class="secondary" type="button">Refresh list</button>
</section>
<section id="player" class="player" hidden>
<div class="player-heading">
@ -34,6 +55,17 @@
<p id="emulator-error" class="error" hidden></p>
</section>
</main>
<dialog id="source-modal" class="source-modal">
<div class="modal-heading">
<div>
<p class="eyebrow">BASIC V2 source</p>
<h2 id="source-title">Program source</h2>
</div>
<button id="close-source" class="icon-button" type="button" aria-label="Close source">×</button>
</div>
<p id="source-status" class="status">Loading source…</p>
<pre id="source-code" hidden><code></code></pre>
</dialog>
<script src="/app.js"></script>
</body>
</html>

View file

@ -10,20 +10,45 @@ body { margin: 0; min-height: 100vh; background: radial-gradient(circle at top,
.site-header { display: flex; justify-content: space-between; gap: 1rem; padding: 1rem clamp(1rem, 5vw, 4rem); border-bottom: 1px solid #ffffff1c; color: #bdb5dd; }
.brand { color: #fff; font-weight: 800; text-decoration: none; }
main { width: min(1100px, calc(100% - 2rem)); margin: 0 auto; padding: clamp(2rem, 6vw, 5rem) 0; }
.panel { max-width: 900px; margin: auto; }
.panel { max-width: 1100px; margin: auto; }
.eyebrow { margin: 0 0 .5rem; color: #c9a7ff; font-size: .75rem; font-weight: 800; letter-spacing: .14em; text-transform: uppercase; }
h1 { margin: 0; font-size: clamp(2rem, 7vw, 4rem); line-height: 1; }
.lede, .hint { color: #c8c1df; }
.lede, .hint { max-width: 780px; color: #c8c1df; font-size: 1.08rem; line-height: 1.65; }
.status { margin: 2rem 0 1rem; color: #9ce6c2; }
.library-toolbar { position: sticky; top: 0; z-index: 10; display: flex; flex-wrap: wrap; gap: .65rem; margin: 2rem 0 0; padding: .75rem; border: 1px solid #ffffff24; border-radius: 12px; background: #17132eef; box-shadow: 0 12px 30px #08061366; backdrop-filter: blur(12px); }
.library-toolbar input, .library-toolbar select { height: 42px; padding: 0 .8rem; border: 1px solid #ffffff2b; border-radius: 8px; background: #292344; color: #fff; font: inherit; }
.library-toolbar input:focus, .library-toolbar select:focus { border-color: #b58cff; outline: 2px solid #b58cff44; }
.search-field { flex: 1 1 260px; }
.search-field input { width: 100%; }
.view-switcher { display: flex; gap: .25rem; padding: .2rem; border-radius: 8px; background: #0e0c1c; }
.view-button, .toolbar-button { height: 42px; margin: 0; border: 0; }
.view-button { min-width: 42px; padding: .4rem; background: transparent; color: #aaa2c8; font-size: 1.2rem; }
.view-button.active { background: #6f4bad; color: #fff; }
.toolbar-button { padding: 0 .9rem; }
.program-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 1rem; }
.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 { display: flex; min-height: 270px; flex-direction: column; align-items: flex-start; padding: 1.25rem; border: 1px solid #ffffff24; border-radius: 14px; background: #211c3bcc; color: #fff; transition: transform .15s, border-color .15s; }
.program-card:hover { transform: translateY(-3px); border-color: #b58cff; }
.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; }
.program-title { margin: 1rem 0 0; font-size: 1.2rem; }
.program-description { margin: .55rem 0 0; 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; }
.program-actions { display: flex; width: 100%; align-items: center; justify-content: space-between; gap: 1rem; margin-top: auto; padding-top: 1.25rem; }
.program-action, .source-action { color: #cdb9ff; font-size: .9rem; }
.program-action { text-decoration: none; }
.program-action:hover, .source-action:hover { color: #fff; }
.source-action { margin: 0; padding: .2rem 0; border: 0; background: none; text-decoration: underline; text-underline-offset: .2rem; }
.program-grid.list-view { grid-template-columns: 1fr; }
.program-grid.list-view .program-card { min-height: 0; display: grid; grid-template-columns: auto minmax(150px, .8fr) minmax(220px, 1.5fr) minmax(180px, 1fr); align-items: center; gap: 1rem; }
.program-grid.list-view .program-title, .program-grid.list-view .program-description, .program-grid.list-view .controls, .program-grid.list-view .program-actions { margin: 0; padding: 0; }
.source-modal { width: min(900px, calc(100% - 2rem)); height: min(80vh, 760px); padding: 0; border: 1px solid #ffffff2b; border-radius: 14px; background: #17142b; color: #fff; box-shadow: 0 30px 80px #000a; }
.source-modal::backdrop { background: #080611cc; backdrop-filter: blur(5px); }
.modal-heading { position: sticky; top: 0; display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem 1.25rem; border-bottom: 1px solid #ffffff1f; background: #17142bf5; }
.modal-heading h2 { margin: 0; }
.icon-button { width: 42px; height: 42px; margin: 0; padding: 0; border-radius: 50%; font-size: 1.7rem; line-height: 1; }
.source-modal > .status { margin: 2rem; }
.source-modal pre { min-height: calc(100% - 82px); margin: 0; padding: 1.5rem; overflow: auto; background: #0c0a17; color: #d9fbdc; font: .92rem/1.55 ui-monospace, SFMono-Regular, Consolas, monospace; text-align: left; white-space: pre; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
.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; }
.player-heading { display: flex; align-items: end; justify-content: space-between; gap: 1rem; }
@ -33,5 +58,8 @@ button:disabled { opacity: .5; cursor: wait; }
@media (max-width: 600px) {
.site-header span { display: none; }
.player-heading { align-items: start; flex-direction: column; }
.library-toolbar { top: .5rem; }
.library-toolbar label, .library-toolbar select { flex: 1 1 100%; width: 100%; }
.program-grid.list-view .program-card { display: flex; }
#game { min-height: 60vh; }
}

View file

@ -27,7 +27,13 @@ function fetchPrograms() {
if (!filePath.startsWith(programDirectory + sep) || !existsSync(filePath) || !statSync(filePath).isFile()) {
throw new Error(`Program file is missing: ${program.path}`);
}
return { ...program, size: statSync(filePath).size };
const sourcePath = program.source || program.path.replace(/\.prg$/i, '.bas');
const sourceFilePath = resolve(programDirectory, sourcePath);
return {
...program,
source: sourceFilePath.startsWith(programDirectory + sep) && existsSync(sourceFilePath) ? sourcePath : null,
size: statSync(filePath).size
};
});
}
@ -48,6 +54,21 @@ function sendProgram(response, programPath) {
createReadStream(filePath).pipe(response);
}
function sendSource(response, programPath) {
const program = fetchPrograms().find(entry => entry.path === programPath);
if (!program?.source) {
sendJson(response, 404, { error: 'Program source not found' });
return;
}
const filePath = resolve(programDirectory, program.source);
response.writeHead(200, {
'Content-Type': 'text/plain; charset=utf-8',
'Content-Length': statSync(filePath).size,
'Cache-Control': 'no-store'
});
createReadStream(filePath).pipe(response);
}
function serveStatic(response, pathname) {
const requested = pathname === '/' ? 'index.html' : pathname.slice(1);
const filePath = normalize(join(publicDirectory, requested));
@ -73,6 +94,8 @@ createServer(async (request, response) => {
sendJson(response, 200, { programs: fetchPrograms() });
} else if (url.pathname === '/api/program') {
sendProgram(response, url.searchParams.get('path') || '');
} else if (url.pathname === '/api/source') {
sendSource(response, url.searchParams.get('path') || '');
} else if (!serveStatic(response, url.pathname)) {
sendJson(response, 404, { error: 'Not found' });
}