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 status = document.querySelector('#status');
const programsElement = document.querySelector('#programs'); const programsElement = document.querySelector('#programs');
const refreshButton = document.querySelector('#refresh'); 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) { function titleFromPath(path) {
const filename = path.split('/').at(-1).replace(/\.prg$/i, ''); const filename = path.split('/').at(-1).replace(/\.prg$/i, '');
@ -21,49 +31,97 @@ const typeIcons = {
Demo: '✦' 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() { async function loadPrograms() {
status.hidden = false; status.hidden = false;
status.className = 'status'; status.className = 'status';
status.textContent = 'Loading programs…'; status.textContent = 'Loading programs…';
programsElement.replaceChildren();
refreshButton.disabled = true; refreshButton.disabled = true;
try { try {
const response = await fetch('/api/programs', { cache: 'no-store' }); const response = await fetch('/api/programs', { cache: 'no-store' });
if (!response.ok) throw new Error('The program list could not be loaded.'); if (!response.ok) throw new Error('The program list could not be loaded.');
const { programs } = await response.json(); const { programs } = await response.json();
for (const program of programs) { allPrograms = programs;
const link = document.createElement('a'); const currentType = typeFilter.value;
link.className = 'program-card'; const types = [...new Set(programs.map(program => program.type))].sort();
link.href = `/?program=${encodeURIComponent(program.path)}`; typeFilter.replaceChildren(new Option('All types', ''), ...types.map(type => new Option(type, type)));
if (types.includes(currentType)) typeFilter.value = currentType;
const pill = document.createElement('span'); renderPrograms();
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.';
} catch (error) { } catch (error) {
status.className = 'status error'; status.className = 'status error';
status.textContent = error.message; status.textContent = error.message;
@ -103,6 +161,27 @@ function startEmulator(programPath) {
} }
refreshButton.addEventListener('click', loadPrograms); 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'); const programPath = new URLSearchParams(location.search).get('program');
if (programPath) startEmulator(programPath); if (programPath) startEmulator(programPath);
else loadPrograms(); else loadPrograms();

View file

@ -15,11 +15,32 @@
<main> <main>
<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>Small programs. Pure 8-bit fun.</h1>
<p class="lede">Programs are read directly from the locally mounted repository each time this page loads.</p> <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> <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>
</section> </section>
<section id="player" class="player" hidden> <section id="player" class="player" hidden>
<div class="player-heading"> <div class="player-heading">
@ -34,6 +55,17 @@
<p id="emulator-error" class="error" hidden></p> <p id="emulator-error" class="error" hidden></p>
</section> </section>
</main> </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> <script src="/app.js"></script>
</body> </body>
</html> </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; } .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; } .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; } 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; } .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; } 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; } .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-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; } .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; } .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-title { margin: 1rem 0 0; font-size: 1.2rem; }
.program-description { margin-top: .55rem; color: #c8c1df; font-size: .92rem; line-height: 1.45; } .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; } .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; } 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, 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; }
@ -33,5 +58,8 @@ button:disabled { opacity: .5; cursor: wait; }
@media (max-width: 600px) { @media (max-width: 600px) {
.site-header span { display: none; } .site-header span { display: none; }
.player-heading { align-items: start; flex-direction: column; } .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; } #game { min-height: 60vh; }
} }

View file

@ -27,7 +27,13 @@ function fetchPrograms() {
if (!filePath.startsWith(programDirectory + sep) || !existsSync(filePath) || !statSync(filePath).isFile()) { if (!filePath.startsWith(programDirectory + sep) || !existsSync(filePath) || !statSync(filePath).isFile()) {
throw new Error(`Program file is missing: ${program.path}`); 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); 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) { function serveStatic(response, pathname) {
const requested = pathname === '/' ? 'index.html' : pathname.slice(1); const requested = pathname === '/' ? 'index.html' : pathname.slice(1);
const filePath = normalize(join(publicDirectory, requested)); const filePath = normalize(join(publicDirectory, requested));
@ -73,6 +94,8 @@ createServer(async (request, response) => {
sendJson(response, 200, { programs: fetchPrograms() }); sendJson(response, 200, { programs: fetchPrograms() });
} else if (url.pathname === '/api/program') { } else if (url.pathname === '/api/program') {
sendProgram(response, url.searchParams.get('path') || ''); sendProgram(response, url.searchParams.get('path') || '');
} else if (url.pathname === '/api/source') {
sendSource(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' });
} }