539 lines
20 KiB
JavaScript
539 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;
|
|
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('/api/list', {cache:'no-store'}).then(r=>{
|
|
if(!r.ok) throw new Error('API unavailable');
|
|
return r.json();
|
|
}).then(data=>{
|
|
cachedItems = Array.isArray(data.items) ? data.items : [];
|
|
return cachedItems;
|
|
}).catch(()=>{
|
|
return fetch('server/data.txt', {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('/events');
|
|
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('/api/list', {
|
|
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=>({"&":"&","<":"<",">":">","\"":""","'":"'"})[c])}
|
|
function escapeAttr(s){return escapeHtml(String(s == null ? '' : s))}
|
|
})();
|