Files
gd-list/app.js
T
miles 906ef15e4b Use live server endpoints; add CORS/basePath
Point the client to a configurable live server (live API, events and data file) and accept both JSON and plain-text list formats. Update app.js to parse JSON or text responses, use live EventSource and PUT endpoints, and fall back to the live data file when needed.

On the server side, add BASE_PATH support (trim trailing slash) and normalize request paths so the app can be mounted under a subpath. Add setCorsHeaders and enable CORS (including handling OPTIONS preflight) for API, events and file responses, return proper headers on errors, and log the base path at startup. These changes enable cross-origin access and deployment under a base path while improving robustness of list parsing.
2026-03-31 18:47:49 -05:00

547 lines
20 KiB
JavaScript

// Lightweight multi-page handler for GD fedl
(function(){
function qs(id){return document.getElementById(id)}
const page = document.body.dataset.page;
const liveServerBase = 'https://raspberrypi-1.tail46eacb.ts.net/fedl';
const liveApiUrl = `${liveServerBase}/api/list`;
const liveEventsUrl = `${liveServerBase}/events`;
const liveDataFileUrl = `${liveServerBase}/server/data.txt`;
let cachedItems = null;
let cachedLevelMeta = null;
let liveBound = false;
let liveHandlers = [];
// Storage helpers
function read(key, fallback){
try{const v = localStorage.getItem(key); return v?JSON.parse(v):fallback}
catch(e){return fallback}
}
function write(key, val){localStorage.setItem(key,JSON.stringify(val))}
function parseData(txt){
return txt.split(/\r?\n/).map(l=>l.trim()).filter(Boolean).map(l=>{
const parts = l.split('|').map(p=>p.trim());
return {level:parts[0]||'Unknown',position:parts[1]||'',title:parts[2]||'Untitled',url:parts[3]||''};
});
}
function formatData(items){
return items.map(item=>[
item.level || 'new',
item.position || '',
item.title || '',
item.url || ''
].join('|')).join('\n');
}
function parseLevelMeta(txt){
const map = {};
txt.split(/\r?\n/).map(l=>l.trim()).filter(Boolean).forEach(l=>{
if(l.startsWith('//')) return;
const parts = l.split('|').map(p=>p.trim());
const title = parts[0] || '';
if(!title) return;
map[title] = {
levelId: parts[1] || 'unknown',
percent: parts[2] || '100'
};
});
return map;
}
function loadItems(){
if(cachedItems) return Promise.resolve(cachedItems);
return fetch(liveApiUrl, {cache:'no-store'}).then(r=>{
if(!r.ok) throw new Error('API unavailable');
const contentType = (r.headers.get('content-type') || '').toLowerCase();
if(contentType.includes('application/json')){
return r.json().then(data=>Array.isArray(data.items) ? data.items : []);
}
return r.text().then(txt=>parseData(txt));
}).then(items=>{
cachedItems = items;
return cachedItems;
}).catch(()=>{
return fetch(liveDataFileUrl, {cache:'no-store'}).then(r=>{
if(!r.ok) throw new Error('server data unavailable');
return r.text();
}).then(txt=>{
cachedItems = parseData(txt);
return cachedItems;
}).catch(()=>{
return fetch('data.txt', {cache:'no-store'}).then(r=>{
if(!r.ok) throw new Error('static data unavailable');
return r.text();
}).then(txt=>{
cachedItems = parseData(txt);
return cachedItems;
});
});
});
}
function clearItemsCache(){
cachedItems = null;
}
function onLiveUpdate(handler){
liveHandlers.push(handler);
}
function notifyLiveUpdate(items){
liveHandlers.forEach(handler=>handler(items));
}
function refreshItems(){
clearItemsCache();
return loadItems().then(items=>{
notifyLiveUpdate(items);
return items;
});
}
function bindLiveUpdates(){
if(liveBound || typeof window.EventSource === 'undefined') return;
liveBound = true;
const source = new EventSource(liveEventsUrl);
source.addEventListener('list-update', ()=>{
refreshItems().catch(err=>console.error(err));
});
source.onerror = function(){
source.close();
liveBound = false;
window.setTimeout(bindLiveUpdates, 3000);
};
}
function loadLevelMeta(){
if(cachedLevelMeta) return Promise.resolve(cachedLevelMeta);
return fetch('level-ids.txt').then(r=>r.text()).then(txt=>{
cachedLevelMeta = parseLevelMeta(txt);
return cachedLevelMeta;
}).catch(()=>{
cachedLevelMeta = {};
return cachedLevelMeta;
});
}
function fetchLevelIdFromApi(title){
const url = `https://gdbrowser.com/api/search/${encodeURIComponent(title)}?diff=-2&demonFilter=5&count=10`;
return fetch(url).then(r=>r.json()).then(results=>{
if(!Array.isArray(results) || !results.length) return null;
const exact = results.find(item=>String(item.name||'').toLowerCase() === String(title||'').toLowerCase());
const match = exact || results[0];
if(!match || !match.id) return null;
return String(match.id);
}).catch(()=>null);
}
if(page==='roulette'){
const spinBtn = qs('roulette-spin');
const statusEl = qs('roulette-status');
const titleEl = qs('roulette-title');
const rankEl = qs('roulette-rank');
const idEl = qs('roulette-level-id');
const noteEl = qs('roulette-note');
const openEl = qs('roulette-open');
function showPick(item, meta){
statusEl.textContent = 'Your demon is:';
titleEl.textContent = item.title;
rankEl.textContent = `Rank: #${item.position}`;
idEl.textContent = `Level ID: ${meta.levelId || 'unknown'}`;
noteEl.textContent = meta.source === 'api'
? 'Level ID was looked up from the Geometry Dash community API.'
: 'Level ID came from your local level-ids.txt file.';
if(item.url){
openEl.hidden = false;
openEl.href = item.url;
}else{
openEl.hidden = true;
}
}
spinBtn.addEventListener('click', ()=>{
statusEl.textContent = 'Spinning...';
titleEl.textContent = 'Choosing a demon';
rankEl.textContent = 'Rank: -';
idEl.textContent = 'Level ID: -';
noteEl.textContent = 'Checking your local file and API if needed.';
openEl.hidden = true;
Promise.all([loadItems(), loadLevelMeta()]).then(([items, metaMap])=>{
if(!items.length){
statusEl.textContent = 'No demons found.';
titleEl.textContent = 'Add demons to server/data.txt';
idEl.textContent = 'Level ID: -';
noteEl.textContent = 'No list data was found.';
return;
}
const item = items[Math.floor(Math.random()*items.length)];
const localMeta = metaMap[item.title] || {levelId:'unknown', percent:'100'};
if(localMeta.levelId && localMeta.levelId !== 'unknown'){
window.setTimeout(()=>showPick(item, {levelId: localMeta.levelId, percent: localMeta.percent, source: 'file'}), 350);
return;
}
fetchLevelIdFromApi(item.title).then(levelId=>{
const meta = {
levelId: levelId || 'unknown',
percent: localMeta.percent || '100',
source: levelId ? 'api' : 'file'
};
window.setTimeout(()=>showPick(item, meta), 350);
});
}).catch(err=>{
statusEl.textContent = 'Could not load the list.';
titleEl.textContent = 'Run the site on a local server';
rankEl.textContent = 'Rank: -';
idEl.textContent = 'Level ID: -';
noteEl.textContent = 'The local file or API lookup failed.';
console.error(err);
});
});
}
// Players page
if(page==='players'){
const form = qs('player-form'); const nameIn = qs('player-name'); const list = qs('players');
if(!form || !nameIn || !list) return;
let players = read('gd_players',[]);
function render(){list.innerHTML=''; players.forEach((p,idx)=>{
const li=document.createElement('li'); li.innerHTML=`<span><span class="name">${escapeHtml(p.name)}</span> <span class="muted">(${p.id})</span></span>`;
const actions=document.createElement('div'); actions.className='actions';
const edit=document.createElement('button'); edit.textContent='Edit'; edit.onclick=()=>{const nv=prompt('Edit name',p.name); if(nv){players[idx].name=nv; write('gd_players',players); render()}};
const del=document.createElement('button'); del.textContent='Delete'; del.onclick=()=>{if(confirm('Delete player?')){players.splice(idx,1); write('gd_players',players); render()}};
actions.appendChild(edit); actions.appendChild(del); li.appendChild(actions); list.appendChild(li);
})}
form.addEventListener('submit',e=>{e.preventDefault(); const name=nameIn.value.trim(); if(!name) return; players.push({id:Date.now().toString(36),name}); write('gd_players',players); nameIn.value=''; render()});
render();
}
function initListPage(){
const levelsEl = qs('levels'); const listArea = qs('list-area'); const titleEl = qs('list-title');
const searchEl = qs('search');
const filterSelect = qs('level-filter');
let currentItems = [];
let controlsBound = false;
// Load hard-coded data file data.txt (category|position|title|url per line)
function loadData(){
loadItems().then(items=>{
applyItems(items);
}).catch(err=>{listArea.innerHTML='<p class="muted">Failed to load list data - run via the Node server.</p>'; console.error(err)});
}
function computeCategories(items){
const max = items.reduce((m,it)=>Math.max(m, Number(it.position)||0), 0);
const cats = ['Full List'];
for(let i=1;i<=max;i+=10){
const start = i; const end = Math.min(i+9, max);
cats.push(`Top ${start}-${end}`);
}
return cats;
}
function setupLevels(items){
const categories = computeCategories(items);
levelsEl.innerHTML='';
filterSelect.innerHTML = '<option value="all">Full List</option>';
categories.forEach(cat=>{
const li = document.createElement('li');
const btn = document.createElement('button');
btn.type = 'button';
btn.textContent = cat;
btn.className = 'level-link';
btn.addEventListener('click', ()=> {
selectLevel(cat, items, btn);
filterSelect.value = cat;
});
li.appendChild(btn);
levelsEl.appendChild(li);
const opt = document.createElement('option'); opt.value = cat; opt.textContent = cat; filterSelect.appendChild(opt);
});
if(!controlsBound){
searchEl.addEventListener('input', ()=> renderTable(currentItems));
filterSelect.addEventListener('change', ()=> renderTable(currentItems));
controlsBound = true;
}
}
function selectLevel(level, items, linkEl){
levelsEl.querySelectorAll('.level-link').forEach(a=>a.classList.remove('active'));
if(linkEl) linkEl.classList.add('active');
qs('level-filter').value = level;
renderTable(items);
}
function renderTable(items){
const q = (searchEl && searchEl.value || '').toLowerCase();
const levelFilter = (filterSelect && filterSelect.value) || 'all';
const filtered = items.filter(it=>{
// apply level / category filter (support range categories like "Top 1-10")
if(levelFilter && levelFilter!=='all' && levelFilter!=='Full List'){
const m = levelFilter.match(/Top\s*(\d+)-(\d+)/i);
if(m){
const s = Number(m[1]); const e = Number(m[2]); const pos = Number(it.position)||0;
if(pos < s || pos > e) return false;
}
}
if(!q) return true;
return (it.title||'').toLowerCase().includes(q) || (it.level||'').toLowerCase().includes(q);
}).sort((a,b)=> (Number(a.position)||0)-(Number(b.position)||0));
const tbody = qs('list-area'); tbody.innerHTML='';
filtered.forEach(it=>{
const tr = document.createElement('tr');
const tdNum = document.createElement('td'); tdNum.textContent = it.position;
const tdTitle = document.createElement('td'); tdTitle.textContent = it.title;
const tdLevel = document.createElement('td'); tdLevel.textContent = it.level;
const tdAct = document.createElement('td');
const a = document.createElement('a'); a.textContent='Open'; a.href='#'; a.className='btn';
a.addEventListener('click', (e)=>{e.preventDefault(); openVideo(it.url)});
tdAct.appendChild(a);
tr.appendChild(tdNum); tr.appendChild(tdTitle); tr.appendChild(tdLevel); tr.appendChild(tdAct);
tbody.appendChild(tr);
});
}
function openVideo(url){
if(!url) return; const id = extractYouTubeID(url);
if(!id){ window.open(url,'_blank'); return }
let modal = document.querySelector('.video-modal');
if(!modal){
modal = document.createElement('div'); modal.className='video-modal';
const inner = document.createElement('div'); inner.className='inner';
const close = document.createElement('button'); close.textContent='Close'; close.className='btn'; close.style.float='right'; close.onclick=()=>modal.remove();
inner.appendChild(close);
const iframe = document.createElement('iframe'); iframe.allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture'; iframe.allowFullscreen=true;
inner.appendChild(iframe); modal.appendChild(inner); document.body.appendChild(modal);
}
modal.querySelector('iframe').src = `https://www.youtube.com/embed/${id}`;
modal.style.display = 'flex';
}
function extractYouTubeID(url){
const m = url.match(/(?:v=|\/embed\/|youtu\.be\/)([A-Za-z0-9_-]{6,})/); return m?m[1]:'';
}
function applyItems(items){
const previousFilter = filterSelect.value || 'all';
currentItems = items.slice();
setupLevels(currentItems);
const availableFilters = Array.from(filterSelect.options).map(option=>option.value);
filterSelect.value = availableFilters.includes(previousFilter) ? previousFilter : 'all';
const activeText = filterSelect.value === 'all' ? 'Full List' : filterSelect.value;
levelsEl.querySelectorAll('.level-link').forEach(btn=>{
btn.classList.toggle('active', btn.textContent === activeText);
});
renderTable(currentItems);
}
loadData();
return {applyItems};
}
// Lists page
if(page==='lists' || page==='serverlist'){
const listPage = initListPage();
if(page==='serverlist'){
bindLiveUpdates();
onLiveUpdate(function(items){
const status = qs('live-status');
if(status){
status.textContent = 'Live data updated';
}
listPage.applyItems(items);
});
}
}
if(page==='admelist'){
const statusEl = qs('admin-status');
const tbody = qs('admin-list-body');
const addBtn = qs('add-row');
const saveBtn = qs('save-list');
const searchEl = qs('admin-search');
let items = [];
function setStatus(message, isError){
if(!statusEl) return;
statusEl.textContent = message;
statusEl.classList.toggle('error-text', !!isError);
}
function filteredItems(){
const query = (searchEl && searchEl.value || '').trim().toLowerCase();
if(!query) return items;
return items.filter(item=>{
return [item.level, item.position, item.title, item.url].some(value=>
String(value || '').toLowerCase().includes(query)
);
});
}
function normalizePositions(){
let position = 1;
items.forEach(item=>{
if(item._isDraft){
item.position = '';
return;
}
item.position = String(position);
position += 1;
});
}
function moveItemToPosition(index, rawPosition){
if(!items[index]) return;
const parsedPosition = Number(rawPosition);
if(!Number.isFinite(parsedPosition) || parsedPosition < 1) return;
const nextPosition = Math.max(1, parsedPosition);
const [item] = items.splice(index, 1);
item._isDraft = false;
const drafts = items.filter(entry=>entry._isDraft);
const ranked = items.filter(entry=>!entry._isDraft);
const targetIndex = Math.min(ranked.length, nextPosition - 1);
ranked.splice(targetIndex, 0, item);
items = drafts.concat(ranked);
normalizePositions();
}
function renderAdminTable(){
const rows = filteredItems();
tbody.innerHTML = '';
rows.forEach((item, index)=>{
const actualIndex = items.indexOf(item);
const tr = document.createElement('tr');
tr.innerHTML = `
<td><input data-field="position" data-index="${actualIndex}" type="number" min="1" value="${escapeAttr(item.position)}"></td>
<td><input data-field="level" data-index="${actualIndex}" type="text" value="${escapeAttr(item.level)}"></td>
<td><input data-field="title" data-index="${actualIndex}" type="text" value="${escapeAttr(item.title)}"></td>
<td><input data-field="url" data-index="${actualIndex}" type="url" value="${escapeAttr(item.url)}"></td>
<td><button type="button" class="btn danger-btn small-btn" data-delete="${actualIndex}">Delete</button></td>
`;
tbody.appendChild(tr);
});
if(!rows.length){
const tr = document.createElement('tr');
tr.innerHTML = '<td colspan="5" class="muted">No rows match your search.</td>';
tbody.appendChild(tr);
}
}
function saveItems(){
const hasUnplacedDraft = items.some(item=>{
const hasContent = String(item.title || '').trim() || String(item.url || '').trim() || String(item.level || '').trim();
return item._isDraft && hasContent;
});
if(hasUnplacedDraft){
setStatus('Give each new row a number before saving.', true);
return Promise.resolve();
}
items = items
.map(item=>({
level: String(item.level || '').trim() || 'new',
position: String(item.position || '').trim(),
title: String(item.title || '').trim(),
url: String(item.url || '').trim()
}))
.filter(item=>item.title);
normalizePositions();
return fetch(liveApiUrl, {
method:'PUT',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({text: formatData(items)})
}).then(r=>{
if(!r.ok) throw new Error('Save failed');
clearItemsCache();
renderAdminTable();
setStatus('Saved. Live pages update automatically.');
}).catch(err=>{
console.error(err);
setStatus('Could not save. Start the Node server and try again.', true);
});
}
function loadAdmin(){
loadItems().then(loaded=>{
items = loaded.slice().sort((a,b)=>(Number(a.position) || 0) - (Number(b.position) || 0)).map(item=>({
level: item.level,
position: item.position,
title: item.title,
url: item.url,
_isDraft: false
}));
normalizePositions();
renderAdminTable();
setStatus('Connected to live list data.');
}).catch(err=>{
console.error(err);
setStatus('Could not load list data. Start the Node server first.', true);
});
}
tbody.addEventListener('input', function(event){
const target = event.target;
const field = target.getAttribute('data-field');
const index = Number(target.getAttribute('data-index'));
if(!field || Number.isNaN(index) || !items[index]) return;
if(field === 'position'){
moveItemToPosition(index, target.value);
renderAdminTable();
}else{
items[index][field] = target.value;
}
setStatus('Unsaved changes');
});
tbody.addEventListener('click', function(event){
const deleteButton = event.target.closest('[data-delete]');
if(!deleteButton) return;
const deleteIndex = deleteButton.getAttribute('data-delete');
if(deleteIndex == null) return;
const index = Number(deleteIndex);
if(Number.isNaN(index)) return;
items.splice(index, 1);
normalizePositions();
renderAdminTable();
setStatus('Row removed. Save when ready.');
});
addBtn.addEventListener('click', function(){
items.unshift({level:'new', position:'', title:'', url:'', _isDraft:true});
normalizePositions();
renderAdminTable();
setStatus('New row added at the top. Give it a number when you want to place it.');
});
saveBtn.addEventListener('click', function(){
saveItems();
});
if(searchEl){
searchEl.addEventListener('input', renderAdminTable);
}
bindLiveUpdates();
onLiveUpdate(function(updatedItems){
items = updatedItems.slice().sort((a,b)=>(Number(a.position) || 0) - (Number(b.position) || 0)).map(item=>({
level: item.level,
position: item.position,
title: item.title,
url: item.url,
_isDraft: false
}));
normalizePositions();
renderAdminTable();
setStatus('List reloaded from live server.');
});
loadAdmin();
}
// Utility
function escapeHtml(s){return String(s).replace(/[&<>"']/g, c=>({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"})[c])}
function escapeAttr(s){return escapeHtml(String(s == null ? '' : s))}
})();