From e13835ab27a5a508bcecaacb3f7ee21c983ce853 Mon Sep 17 00:00:00 2001 From: Christian Johannesen Date: Wed, 7 Oct 2026 21:22:58 +0200 Subject: [PATCH] Add library tools and BASIC source modal --- public/app.js | 147 +++++++++++++++++++++++++++++++++++----------- public/index.html | 38 +++++++++++- public/styles.css | 40 +++++++++++-- server.mjs | 25 +++++++- 4 files changed, 206 insertions(+), 44 deletions(-) diff --git a/public/app.js b/public/app.js index 7358a26..4eb8943 100644 --- a/public/app.js +++ b/public/app.js @@ -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(); diff --git a/public/index.html b/public/index.html index 34df03d..97df76e 100644 --- a/public/index.html +++ b/public/index.html @@ -15,11 +15,32 @@

Program library

-

Choose a program

-

Programs are read directly from the locally mounted repository each time this page loads.

+

Small programs. Pure 8-bit fun.

+

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.

+
+ + + +
+ + +
+ +

Loading programs…

-
+ + +

Loading source…

+ +
diff --git a/public/styles.css b/public/styles.css index 8cbb65c..c9bc371 100644 --- a/public/styles.css +++ b/public/styles.css @@ -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; } } diff --git a/server.mjs b/server.mjs index 3ac0f74..312ebf9 100644 --- a/server.mjs +++ b/server.mjs @@ -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' }); }