Add library tools and BASIC source modal
This commit is contained in:
parent
4fdbae2c8a
commit
e13835ab27
4 changed files with 206 additions and 44 deletions
121
public/app.js
121
public/app.js
|
|
@ -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,34 +31,35 @@ const typeIcons = {
|
||||||
Demo: '✦'
|
Demo: '✦'
|
||||||
};
|
};
|
||||||
|
|
||||||
async function loadPrograms() {
|
function renderPrograms() {
|
||||||
status.hidden = false;
|
const query = searchInput.value.trim().toLowerCase();
|
||||||
status.className = 'status';
|
const selectedType = typeFilter.value;
|
||||||
status.textContent = 'Loading programs…';
|
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();
|
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) {
|
for (const program of programs) {
|
||||||
const link = document.createElement('a');
|
const card = document.createElement('article');
|
||||||
link.className = 'program-card';
|
card.className = 'program-card';
|
||||||
link.href = `/?program=${encodeURIComponent(program.path)}`;
|
|
||||||
|
|
||||||
const pill = document.createElement('span');
|
const pill = document.createElement('span');
|
||||||
pill.className = 'type-pill';
|
pill.className = 'type-pill';
|
||||||
pill.textContent = `${typeIcons[program.type] || '●'} ${program.type}`;
|
pill.textContent = `${typeIcons[program.type] || '●'} ${program.type}`;
|
||||||
|
|
||||||
const title = document.createElement('strong');
|
const title = document.createElement('h2');
|
||||||
title.className = 'program-title';
|
title.className = 'program-title';
|
||||||
title.textContent = program.title;
|
title.textContent = program.title;
|
||||||
|
|
||||||
const description = document.createElement('span');
|
const description = document.createElement('p');
|
||||||
description.className = 'program-description';
|
description.className = 'program-description';
|
||||||
description.textContent = program.description;
|
description.textContent = program.description;
|
||||||
|
|
||||||
const controls = document.createElement('span');
|
const controls = document.createElement('div');
|
||||||
controls.className = 'controls';
|
controls.className = 'controls';
|
||||||
controls.setAttribute('aria-label', `Controls: ${program.controls.join(', ')}`);
|
controls.setAttribute('aria-label', `Controls: ${program.controls.join(', ')}`);
|
||||||
for (const control of program.controls) {
|
for (const control of program.controls) {
|
||||||
|
|
@ -57,13 +68,60 @@ async function loadPrograms() {
|
||||||
controls.append(key);
|
controls.append(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
const action = document.createElement('span');
|
const actions = document.createElement('div');
|
||||||
action.className = 'program-action';
|
actions.className = 'program-actions';
|
||||||
action.textContent = 'Run program →';
|
const run = document.createElement('a');
|
||||||
link.append(pill, title, description, controls, action);
|
run.className = 'program-action primary-action';
|
||||||
programsElement.append(link);
|
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 ? `${programs.length} programs available` : 'No PRG files found.';
|
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…';
|
||||||
|
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();
|
||||||
|
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) {
|
} 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();
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
25
server.mjs
25
server.mjs
|
|
@ -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' });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue