const library = document.querySelector('#library'); 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 categoryFilter = document.querySelector('#category-filter'); const languageFilter = document.querySelector('#language-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 sourceKind = document.querySelector('#source-kind'); const sourceStatus = document.querySelector('#source-status'); const sourceCode = document.querySelector('#source-code'); const programBadges = document.querySelector('#program-badges'); const programDescription = document.querySelector('#program-description'); const programReleaseDate = document.querySelector('#program-release-date'); const programControls = document.querySelector('#program-controls'); const programSentinel = document.querySelector('#program-sentinel'); let allPrograms = []; let filteredPrograms = []; let visibleProgramCount = 0; const PROGRAM_BATCH_SIZE = 12; let programObserver; const categoryMeta = { Arcade: '🎮', Creative: '✎', Demo: '✦', Dice: '⚄', Educational: '⌁', Logic: '◆', Memory: '◫', Music: '♪', Puzzle: '⌘', Reflex: '⚡', Simulation: '⚙', Strategy: '♟', Trivia: '?', Utility: '⚒', Word: 'Aa' }; const languageLabels = { basic: 'BASIC', 'machine-code': 'Machine Code' }; function slugify(value) { return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); } function categoryFromRoute() { const match = location.pathname.match(/^\/category\/([a-z0-9]+(?:-[a-z0-9]+)*)\/?$/); return match?.[1] || ''; } function createCategoryPill(category) { const iconName = categoryMeta[category] || '●'; const pill = document.createElement('a'); pill.className = 'category-pill'; pill.href = `/category/${slugify(category)}`; const icon = document.createElement('span'); icon.setAttribute('aria-hidden', 'true'); icon.textContent = iconName; const label = document.createElement('span'); label.textContent = category; pill.append(icon, label); pill.setAttribute('aria-label', `Show all ${category} programs`); return pill; } function createLanguageBadge(language) { const badge = document.createElement('span'); badge.className = `language-badge language-${language}`; badge.textContent = languageLabels[language] || language; return badge; } function createKeyGroup(keys) { const group = document.createElement('span'); group.className = 'key-group'; for (const keyName of keys) { const key = document.createElement('kbd'); key.textContent = keyName; group.append(key); } return group; } function searchText(program) { const controls = program.controls.flatMap(control => [...control.keys, control.description]); return `${program.title} ${program.description} ${program.category} ${languageLabels[program.language]} ${controls.join(' ')}`.toLowerCase(); } function formatReleaseDate(value) { return new Intl.DateTimeFormat('en-GB', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' }).format(new Date(`${value}T00:00:00Z`)); } function createProgramCard(program) { const card = document.createElement('article'); card.className = 'program-card'; const programUrl = `/program/${encodeURIComponent(program.slug)}`; card.addEventListener('click', event => { if (!event.target.closest('a, button')) location.href = programUrl; }); const badges = document.createElement('div'); badges.className = 'card-badges'; badges.append(createCategoryPill(program.category), createLanguageBadge(program.language)); 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 releaseDate = document.createElement('p'); releaseDate.className = 'release-date'; releaseDate.textContent = `Released ${formatReleaseDate(program.releaseDate)}`; const actions = document.createElement('div'); actions.className = 'program-actions'; const run = document.createElement('a'); run.className = 'card-action'; run.href = programUrl; run.innerHTML = 'Run'; const source = document.createElement('button'); source.className = 'card-action'; source.type = 'button'; source.innerHTML = 'Source'; source.disabled = !program.source; source.addEventListener('click', () => showSource(program)); actions.append(run, source); card.append(badges, title, description, releaseDate, actions); return card; } function appendProgramBatch() { const nextCount = Math.min(visibleProgramCount + PROGRAM_BATCH_SIZE, filteredPrograms.length); const fragment = document.createDocumentFragment(); for (const program of filteredPrograms.slice(visibleProgramCount, nextCount)) fragment.append(createProgramCard(program)); programsElement.append(fragment); visibleProgramCount = nextCount; programSentinel.hidden = visibleProgramCount >= filteredPrograms.length; status.className = 'status'; status.textContent = `${visibleProgramCount} of ${filteredPrograms.length} matching programs shown · ${allPrograms.length} total`; } function observeProgramSentinel() { programObserver.disconnect(); if (!programSentinel.hidden) requestAnimationFrame(() => programObserver.observe(programSentinel)); } function loadBatchNearViewport() { if (programSentinel.hidden || visibleProgramCount >= filteredPrograms.length) return; if (programSentinel.getBoundingClientRect().top <= window.innerHeight + 500) appendProgramBatch(); } function renderPrograms() { const query = searchInput.value.trim().toLowerCase(); const selectedCategory = categoryFilter.value; const selectedLanguage = languageFilter.value; filteredPrograms = allPrograms .filter(program => !selectedCategory || program.category === selectedCategory) .filter(program => !selectedLanguage || program.language === selectedLanguage) .filter(program => !query || searchText(program).includes(query)) .sort((left, right) => { if (sortOrder.value === 'category') return left.category.localeCompare(right.category) || left.title.localeCompare(right.title); if (sortOrder.value === 'newest') return right.releaseDate.localeCompare(left.releaseDate) || left.title.localeCompare(right.title); if (sortOrder.value === 'oldest') return left.releaseDate.localeCompare(right.releaseDate) || left.title.localeCompare(right.title); return left.title.localeCompare(right.title); }); programsElement.replaceChildren(); visibleProgramCount = 0; appendProgramBatch(); observeProgramSentinel(); } async function showSource(program) { sourceTitle.textContent = program.title; sourceKind.textContent = program.language === 'machine-code' ? 'Assembly source' : 'BASIC V2 source'; 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; } } function populateFilters(programs) { const categories = [...new Set(programs.map(program => program.category))].sort(); categoryFilter.replaceChildren(new Option('All categories', ''), ...categories.map(category => new Option(category, category))); const routeSlug = categoryFromRoute(); const routeCategory = categories.find(category => slugify(category) === routeSlug); if (routeCategory) categoryFilter.value = routeCategory; else categoryFilter.value = ''; } 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; populateFilters(programs); renderPrograms(); } catch (error) { status.className = 'status error'; status.textContent = error.message; } finally { refreshButton.disabled = false; } } function renderProgramDetails(program) { programBadges.replaceChildren(createCategoryPill(program.category), createLanguageBadge(program.language)); programDescription.textContent = program.description; programReleaseDate.textContent = `Released ${formatReleaseDate(program.releaseDate)}`; programControls.replaceChildren(); for (const control of program.controls) { const row = document.createElement('div'); row.className = 'control-row'; const description = document.createElement('span'); description.textContent = control.description; row.append(createKeyGroup(control.keys), description); programControls.append(row); } } function startEmulator(program) { library.hidden = true; player.hidden = false; document.title = `${program.title} — C64 Program Collection`; document.querySelector('#program-title').textContent = program.title; renderProgramDetails(program); window.EJS_player = '#game'; window.EJS_core = 'vice_x64sc'; window.EJS_gameUrl = `/api/program?path=${encodeURIComponent(program.path)}`; window.EJS_gameName = program.path.split('/').at(-1); window.EJS_pathtodata = 'https://cdn.emulatorjs.org/stable/data/'; window.EJS_startOnLoaded = true; window.EJS_language = 'en-US'; window.EJS_defaultOptions = { menubarBehavior: 'anywhere', keyboardInput: 'enabled', lockMouse: 'disabled' }; const loader = document.createElement('script'); loader.src = `${window.EJS_pathtodata}loader.js`; loader.onerror = () => { const message = document.querySelector('#emulator-error'); message.hidden = false; message.textContent = 'The WASM VICE emulator could not be downloaded. Check your connection and try again.'; }; document.body.append(loader); } async function loadProgramPage(slug) { library.hidden = true; player.hidden = false; 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(); const program = programs.find(entry => entry.slug === slug); if (!program) throw new Error('This program does not exist.'); startEmulator(program); } catch (error) { const message = document.querySelector('#emulator-error'); message.hidden = false; message.textContent = error.message; } } function setCategory(category) { categoryFilter.value = category; const url = category ? `/category/${slugify(category)}` : '/'; history.pushState({}, '', url); renderPrograms(); } refreshButton.addEventListener('click', loadPrograms); searchInput.addEventListener('input', renderPrograms); categoryFilter.addEventListener('change', () => setCategory(categoryFilter.value)); languageFilter.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(); }); window.addEventListener('popstate', () => { if (!library.hidden) { populateFilters(allPrograms); renderPrograms(); } }); programObserver = new IntersectionObserver(entries => { if (entries.some(entry => entry.isIntersecting) && visibleProgramCount < filteredPrograms.length) appendProgramBatch(); }, { rootMargin: '500px 0px' }); window.addEventListener('scroll', loadBatchNearViewport, { passive: true }); window.addEventListener('resize', loadBatchNearViewport); 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 legacyProgramPath = new URLSearchParams(location.search).get('program'); const programPage = location.pathname.match(/^\/program\/([a-z0-9]+(?:-[a-z0-9]+)*)\/?$/); if (legacyProgramPath) { const slug = legacyProgramPath.split('/')[0]; location.replace(`/program/${encodeURIComponent(slug)}`); } else if (programPage) loadProgramPage(decodeURIComponent(programPage[1])); else loadPrograms();