This commit is contained in:
2026-03-30 23:22:55 -05:00
parent 0fd536c799
commit b5cab84f5f
13 changed files with 1001 additions and 69 deletions
+30 -15
View File
@@ -1,29 +1,37 @@
# GD FEDL
A simple static website for a Geometry Dash FEDL-style list.
A lightweight Geometry Dash FEDL-style website with a built-in Node.js server and browser-based list manager.
It includes:
- a home page
- a list page for ranked level videos
- a manage page for editing the list in the browser
- a players page
- a rules page
- a Node.js server with a JSON API
## Project Files
- `index.html` - home page
- `lists.html` - main list view
- `manage.html` - browser UI for adding, editing, and deleting list entries
- `players.html` - player manager
- `rules.html` - submission rules and mod guidelines
- `app.js` - client-side logic for the list and players pages
- `styles.css` - site styling
- `data.txt` - list data used by the videos page
- `server.js` - Node.js server and API
- `data.json` - primary list storage generated by the server
- `data.txt` - compatibility export rewritten by the server
- `package.json` - start script for the Node server
## How The List Data Works
The site reads `data.txt` and shows the entries on `lists.html`.
The site now serves list data through `GET /api/levels`.
Each line in `data.txt` uses this format:
The server stores entries in `data.json` and also keeps `data.txt` updated automatically for compatibility.
Legacy `data.txt` lines still use this format:
```txt
category|position|title|url
@@ -37,12 +45,10 @@ new|1|Flamewall|https://youtu.be/x4Io4zkWVRw
## Running The Site
Because the list page uses `fetch()` to load `data.txt`, you should run the project with a local server instead of opening the HTML files directly.
Example with Python:
Start the Node server:
```bash
python3 -m http.server 8000
npm start
```
Then open:
@@ -53,20 +59,29 @@ http://localhost:8000
## Editing The List
To add a new level, add a new line to `data.txt` using the same format:
Open:
```txt
new|51|Level Name|https://youtube.com/watch?v=example
http://localhost:8000/manage.html
```
Tips:
From there you can:
- keep positions numeric so sorting works correctly
- use a valid YouTube link if you want the in-page video modal to work
- avoid blank lines with partial data
- add entries
- edit existing entries
- delete entries
- search the current list
The server also exposes these API routes:
- `GET /api/levels`
- `POST /api/levels`
- `PUT /api/levels/:id`
- `DELETE /api/levels/:id`
## Notes
- On first run, the server will migrate the current `data.txt` into `data.json`
- Player data is stored in the browser with `localStorage`
- The players list is local to each browser/device
- The current layout is lightweight and does not need a build step
- No external npm packages are required
+292 -53
View File
@@ -6,17 +6,23 @@
let cachedItems = null;
let cachedLevelMeta = null;
// 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=>{
return txt.split(/\r?\n/).map(l=>l.trim()).filter(Boolean).map((l, index)=>{
const parts = l.split('|').map(p=>p.trim());
return {level:parts[0]||'Unknown',position:parts[1]||'',title:parts[2]||'Untitled',url:parts[3]||''};
return {
id:`legacy-${index + 1}`,
level:parts[0]||'Unknown',
position:parts[1]||'',
title:parts[2]||'Untitled',
url:parts[3]||''
};
});
}
@@ -35,11 +41,32 @@
return map;
}
function invalidateItems(){cachedItems = null}
function loadItems(){
if(cachedItems) return Promise.resolve(cachedItems);
return fetch('data.txt').then(r=>r.text()).then(txt=>{
cachedItems = parseData(txt);
return fetch('/api/levels', {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('data.txt').then(r=>r.text()).then(txt=>{
cachedItems = parseData(txt);
return cachedItems;
});
});
}
function requestJson(url, options){
return fetch(url, Object.assign({
cache:'no-store',
headers:{'Content-Type':'application/json'}
}, options || {})).then(async r=>{
const data = await r.json().catch(()=>({}));
if(!r.ok) throw new Error(data.error || 'Request failed.');
return data;
});
}
@@ -65,6 +92,41 @@
}).catch(()=>null);
}
function extractYouTubeID(url){
const m = String(url || '').match(/(?:v=|\/embed\/|youtu\.be\/)([A-Za-z0-9_-]{6,})/);
return m ? m[1] : '';
}
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';
}
if(page==='roulette'){
const spinBtn = qs('roulette-spin');
const statusEl = qs('roulette-status');
@@ -100,7 +162,7 @@
Promise.all([loadItems(), loadLevelMeta()]).then(([items, metaMap])=>{
if(!items.length){
statusEl.textContent = 'No demons found.';
titleEl.textContent = 'Add demons to data.txt';
titleEl.textContent = 'Add demons from the manage page';
idEl.textContent = 'Level ID: -';
noteEl.textContent = 'No list data was found.';
return;
@@ -121,7 +183,7 @@
});
}).catch(err=>{
statusEl.textContent = 'Could not load the list.';
titleEl.textContent = 'Run the site on a local server';
titleEl.textContent = 'Run the Node server';
rankEl.textContent = 'Rank: -';
idEl.textContent = 'Level ID: -';
noteEl.textContent = 'The local file or API lookup failed.';
@@ -130,36 +192,64 @@
});
}
// Players page
if(page==='players'){
const form = qs('player-form'); const nameIn = qs('player-name'); const list = qs('players');
const form = qs('player-form');
const nameIn = qs('player-name');
const list = qs('players');
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()});
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();
}
// Lists page
if(page==='lists'){
const levelsEl = qs('levels'); const listArea = qs('list-area'); const titleEl = qs('list-title');
// Load hard-coded data file data.txt (category|position|title|url per line)
const levelsEl = qs('levels');
const listArea = qs('list-area');
function loadData(){
loadItems().then(items=>{
setupLevels(items);
}).catch(err=>{listArea.innerHTML='<p class="muted">Failed to load data.txt — run via a local server.</p>'; console.error(err)});
}).catch(err=>{
listArea.innerHTML='<tr><td colspan="3" class="muted">Failed to load list data. Start the Node server and try again.</td></tr>';
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);
const start = i;
const end = Math.min(i+9, max);
cats.push(`Top ${start}-${end}`);
}
return cats;
@@ -183,12 +273,13 @@
li.appendChild(btn);
levelsEl.appendChild(li);
const opt = document.createElement('option'); opt.value = cat; opt.textContent = cat; filterSelect.appendChild(opt);
const opt = document.createElement('option');
opt.value = cat;
opt.textContent = cat;
filterSelect.appendChild(opt);
});
// search and filter handlers
qs('search').addEventListener('input', ()=> renderTable(items));
filterSelect.addEventListener('change', ()=> renderTable(items));
// default select Full List
selectLevel('Full List', items, levelsEl.querySelector('.level-link'));
}
@@ -203,11 +294,12 @@
const q = (qs('search') && qs('search').value || '').toLowerCase();
const levelFilter = (qs('level-filter') && qs('level-filter').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;
const s = Number(m[1]);
const e = Number(m[2]);
const pos = Number(it.position)||0;
if(pos < s || pos > e) return false;
}
}
@@ -215,43 +307,190 @@
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='';
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 tdNum = document.createElement('td');
tdNum.textContent = it.position;
const tdTitle = document.createElement('td');
tdTitle.textContent = it.title;
const tdAct = document.createElement('td');
const a = document.createElement('a'); a.textContent='Open'; a.href='#'; a.className='btn';
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(tdAct);
tr.appendChild(tdNum);
tr.appendChild(tdTitle);
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]:'';
}
loadData();
}
// Utility
if(page==='manage'){
const form = qs('level-form');
const statusEl = qs('manage-status');
const listEl = qs('manage-list');
const searchEl = qs('manage-search');
const formTitleEl = qs('manage-form-title');
const submitEl = qs('level-submit');
const resetEl = qs('level-reset');
const idEl = qs('level-id');
const categoryEl = qs('level-category');
const positionEl = qs('level-position');
const titleEl = qs('level-title');
const urlEl = qs('level-url');
let items = [];
function setStatus(message, isError){
statusEl.textContent = message;
statusEl.style.color = isError ? '#ffb4b4' : '';
}
function resetForm(){
form.reset();
idEl.value = '';
categoryEl.value = 'new';
formTitleEl.textContent = 'Add Entry';
submitEl.textContent = 'Save Entry';
setStatus(`Loaded ${items.length} entr${items.length===1?'y':'ies'} from the server.`, false);
}
function fillForm(item){
idEl.value = item.id || '';
categoryEl.value = item.level || 'new';
positionEl.value = item.position || '';
titleEl.value = item.title || '';
urlEl.value = item.url || '';
formTitleEl.textContent = `Edit #${item.position}`;
submitEl.textContent = 'Update Entry';
window.scrollTo({top:0, behavior:'smooth'});
}
function renderItems(){
const q = (searchEl.value || '').trim().toLowerCase();
const visible = items.filter(item=>{
if(!q) return true;
return String(item.title||'').toLowerCase().includes(q) || String(item.level||'').toLowerCase().includes(q);
}).sort((a,b)=>Number(a.position)-Number(b.position));
listEl.innerHTML = '';
if(!visible.length){
const tr = document.createElement('tr');
const td = document.createElement('td');
td.colSpan = 5;
td.className = 'muted';
td.textContent = 'No entries match the current search.';
tr.appendChild(td);
listEl.appendChild(tr);
return;
}
visible.forEach(item=>{
const tr = document.createElement('tr');
const posTd = document.createElement('td');
posTd.textContent = item.position;
const catTd = document.createElement('td');
catTd.textContent = item.level;
const titleTd = document.createElement('td');
titleTd.textContent = item.title;
const linkTd = document.createElement('td');
if(item.url){
const link = document.createElement('a');
link.href = item.url;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.className = 'table-link';
link.textContent = 'Open video';
linkTd.appendChild(link);
}else{
linkTd.textContent = '-';
}
const actionsTd = document.createElement('td');
const actions = document.createElement('div');
actions.className = 'table-actions';
const editBtn = document.createElement('button');
editBtn.type = 'button';
editBtn.className = 'btn';
editBtn.textContent = 'Edit';
editBtn.addEventListener('click', ()=>fillForm(item));
const deleteBtn = document.createElement('button');
deleteBtn.type = 'button';
deleteBtn.className = 'btn ghost-btn';
deleteBtn.textContent = 'Delete';
deleteBtn.addEventListener('click', ()=>{
if(!confirm(`Delete "${item.title}"?`)) return;
setStatus(`Deleting "${item.title}"...`, false);
requestJson(`/api/levels/${encodeURIComponent(item.id)}`, {method:'DELETE'})
.then(data=>{
items = Array.isArray(data.items) ? data.items : [];
invalidateItems();
renderItems();
resetForm();
})
.catch(error=>setStatus(error.message, true));
});
actions.appendChild(editBtn);
actions.appendChild(deleteBtn);
actionsTd.appendChild(actions);
tr.appendChild(posTd);
tr.appendChild(catTd);
tr.appendChild(titleTd);
tr.appendChild(linkTd);
tr.appendChild(actionsTd);
listEl.appendChild(tr);
});
}
function loadManageItems(){
setStatus('Loading current entries from the server.', false);
loadItems().then(data=>{
items = Array.isArray(data) ? [...data] : [];
renderItems();
resetForm();
}).catch(error=>setStatus(error.message || 'Could not load entries.', true));
}
form.addEventListener('submit', e=>{
e.preventDefault();
const payload = {
level: categoryEl.value.trim() || 'new',
position: positionEl.value.trim(),
title: titleEl.value.trim(),
url: urlEl.value.trim()
};
const id = idEl.value.trim();
const isEdit = Boolean(id);
setStatus(isEdit ? `Updating "${payload.title}"...` : `Creating "${payload.title}"...`, false);
requestJson(isEdit ? `/api/levels/${encodeURIComponent(id)}` : '/api/levels', {
method: isEdit ? 'PUT' : 'POST',
body: JSON.stringify(payload)
}).then(data=>{
items = Array.isArray(data.items) ? data.items : items;
invalidateItems();
renderItems();
resetForm();
}).catch(error=>setStatus(error.message, true));
});
resetEl.addEventListener('click', resetForm);
searchEl.addEventListener('input', renderItems);
loadManageItems();
}
function escapeHtml(s){return String(s).replace(/[&<>"']/g, c=>({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"})[c])}
})();
+352
View File
@@ -0,0 +1,352 @@
[
{
"id": "legacy-1",
"level": "new",
"position": "1",
"title": "Flamewall",
"url": "https://youtu.be/x4Io4zkWVRw?si=tf0DBaWahOPPEWxO"
},
{
"id": "legacy-2",
"level": "new",
"position": "2",
"title": "Thinking Space II",
"url": "https://youtu.be/CELNmHwln_c?si=soP6SzRn92G1vKbl"
},
{
"id": "legacy-3",
"level": "new",
"position": "4",
"title": "Amethyst",
"url": "https://youtu.be/4lfkzz1VCbA?si=6NNeWiH-nnwu21Cb"
},
{
"id": "legacy-4",
"level": "new",
"position": "3",
"title": "Tidal Wave",
"url": "https://youtu.be/9fsZ014qB3s?si=oj4E4ceyNtRVcDvd"
},
{
"id": "legacy-5",
"level": "new",
"position": "5",
"title": "Orbit",
"url": "https://youtu.be/QKcv8DkNPd0?si=cKBFqp7GRqmfupgJ"
},
{
"id": "legacy-6",
"level": "new",
"position": "6",
"title": "BOOBAWAMBA",
"url": "https://youtu.be/20fYiqLAo_E?si=QSSixz31rWeeQ_8B"
},
{
"id": "legacy-7",
"level": "new",
"position": "7",
"title": "Nullscapes",
"url": "https://youtu.be/EztneTPp5CU?si=ljn-S_bnGYwOoUyV"
},
{
"id": "legacy-8",
"level": "new",
"position": "8",
"title": "Quantuese Processing",
"url": "https://youtu.be/j5NC0u1Q91Q?si=FvUEY2kdDtNMqWSD"
},
{
"id": "legacy-9",
"level": "new",
"position": "9",
"title": "Subsuming Vortex",
"url": "https://youtu.be/0eYG1ogJpIQ?si=7XRxfDsmMVb0G9Si"
},
{
"id": "legacy-10",
"level": "new",
"position": "10",
"title": "Andromeda",
"url": "https://youtu.be/mk3TDemdkC0?si=va13_RKAJUyFvhcv"
},
{
"id": "legacy-11",
"level": "new",
"position": "11",
"title": "Every End",
"url": "https://youtu.be/AO--mVVFtKI?si=gIT5vxyc1FZ4_8Jj"
},
{
"id": "legacy-12",
"level": "new",
"position": "12",
"title": "Silent Clubstep",
"url": "https://youtu.be/GR4OMkS3SN8?si=CMNz8bPBaUU94wiy"
},
{
"id": "legacy-13",
"level": "new",
"position": "13",
"title": "Anathema",
"url": "https://youtu.be/_uKwmjHmySI?si=PgXwX7Z--wCay34b"
},
{
"id": "legacy-14",
"level": "new",
"position": "14",
"title": "Acheron",
"url": "https://youtu.be/sBKR6aUorzA?si=0A9S2_W8gi1awZ81"
},
{
"id": "legacy-15",
"level": "new",
"position": "15",
"title": "Ashley Wave Trials",
"url": "https://youtu.be/aTxt76U3e2Q?si=RVcEcOTOfs7NGWRi"
},
{
"id": "legacy-16",
"level": "new",
"position": "16",
"title": "Avernus",
"url": "https://youtu.be/16Zh8jssanc?si=0o6d2dH4p_J7UjKB"
},
{
"id": "legacy-17",
"level": "new",
"position": "17",
"title": "Menace",
"url": "https://youtu.be/nnkgghxxsEE?si=OuUQfD7WbXVooYyX"
},
{
"id": "legacy-18",
"level": "new",
"position": "18",
"title": "Spectre",
"url": "https://youtu.be/MzsSLKJrLSI?si=-bXtr_jG96kTCzy4"
},
{
"id": "legacy-19",
"level": "new",
"position": "19",
"title": "Abyss Of Darkness",
"url": "https://youtu.be/ejJkpqcMMCY?si=Rpp4HEi1kOMh-p0h"
},
{
"id": "legacy-20",
"level": "new",
"position": "20",
"title": "Defeated Circles",
"url": "https://youtu.be/nU5AQPzd2YA?si=6eKDMPh-2QjDBBOF"
},
{
"id": "legacy-21",
"level": "new",
"position": "21",
"title": "Tunnel Of Despair",
"url": "https://youtu.be/LpS4JREhW98?si=Z6_DRV5YZTEA0TeQ"
},
{
"id": "legacy-22",
"level": "new",
"position": "22",
"title": "Subterminal Point",
"url": "https://youtu.be/h2wmRMgACH4?si=nDD0-bmLqPgcdVJP"
},
{
"id": "legacy-23",
"level": "new",
"position": "23",
"title": "KOCMOC",
"url": "https://youtu.be/2CxE-UWCIG4?si=9xz4hBjtNKe8Rcbs"
},
{
"id": "legacy-24",
"level": "new",
"position": "24",
"title": "Slaughterhouse",
"url": "https://youtu.be/kpcF1-QAHQc?si=7ybF9EbcjqHzloLM"
},
{
"id": "legacy-25",
"level": "new",
"position": "25",
"title": "Kyouki",
"url": "https://youtu.be/KDa5c0CJTHs?si=WU0sPusFLB7Jtkam"
},
{
"id": "legacy-26",
"level": "new",
"position": "26",
"title": "The Lightning Rod",
"url": "https://youtu.be/nQDTi077O6M?si=Kqp2Od3lJx1YWTDB"
},
{
"id": "legacy-27",
"level": "new",
"position": "27",
"title": "Based After Based",
"url": "https://youtu.be/yQBFyUvB3lY?si=Cmxbc5jrTrNlcmG5"
},
{
"id": "legacy-28",
"level": "new",
"position": "28",
"title": "CHIL",
"url": "https://youtu.be/DROMiCc2ZRM?si=_bR8CCnqJGVCex9F"
},
{
"id": "legacy-29",
"level": "new",
"position": "29",
"title": "Sakupen Circles",
"url": "https://youtu.be/ofG2mJi9kEA?si=3yv78_jxidKPhtL5"
},
{
"id": "legacy-30",
"level": "new",
"position": "30",
"title": "Deimos (ItsHybrid)",
"url": "https://youtu.be/b2yHaIk5zio?si=AMIuVFR-EW4CK8_P"
},
{
"id": "legacy-31",
"level": "new",
"position": "31",
"title": "Eyes In The Water",
"url": "https://youtu.be/yvLwiOy3KEA?si=1991xs25WTEoTbE0"
},
{
"id": "legacy-32",
"level": "new",
"position": "32",
"title": "Voltage",
"url": "https://youtu.be/wBRvBN9tmlc?si=GU2md9I7ZnUV7Rfr"
},
{
"id": "legacy-33",
"level": "new",
"position": "33",
"title": "Firework",
"url": "https://youtu.be/QBe5x2o9v2w?si=dhPg9YcCRjwngxU9"
},
{
"id": "legacy-34",
"level": "new",
"position": "34",
"title": "Silentlocked",
"url": "https://youtu.be/O-IQeUdEGvI?si=KeSHH6kwZoSVGINT"
},
{
"id": "legacy-35",
"level": "new",
"position": "35",
"title": "poocubed",
"url": "https://youtu.be/fzL31vai1ms?si=9Gwk7RDtbEFxOJ6t"
},
{
"id": "legacy-36",
"level": "new",
"position": "36",
"title": "KOSETSU",
"url": "https://youtu.be/hZ8vFX8z_BU?si=P6fFal0yazJbje2O"
},
{
"id": "legacy-37",
"level": "new",
"position": "37",
"title": "Through The Gates",
"url": "https://youtu.be/4yHA6jux5UI?si=nstk3A6FDN3QR-qm"
},
{
"id": "legacy-38",
"level": "new",
"position": "38",
"title": "Saul Goodman",
"url": "https://youtu.be/hjs5PjUaw9k?si=qJpGrru21d50gO5R"
},
{
"id": "legacy-39",
"level": "new",
"position": "39",
"title": "The Salt Factory",
"url": "https://youtu.be/lQ7M-Sgov24?si=nXYVL0XGy5NOJLXn"
},
{
"id": "legacy-40",
"level": "new",
"position": "40",
"title": "Snowbound",
"url": "https://youtu.be/cjHwgbtAkXU?si=ghJbRJCkN-m0zu60"
},
{
"id": "legacy-41",
"level": "new",
"position": "41",
"title": "CONVOLSION",
"url": "https://youtu.be/qeRKuyU3eGI?si=UPaem0b3V8fX0L1r"
},
{
"id": "legacy-42",
"level": "new",
"position": "42",
"title": "The Apocalyptic Trilogy",
"url": "https://youtu.be/RUBbpsTR5eU?si=mEsPtyMLrQgP5fWl"
},
{
"id": "legacy-43",
"level": "new",
"position": "43",
"title": "MINUSdry",
"url": "https://youtu.be/YvA8ehhzz0Q?si=DT6DfrXOQ41swSIR"
},
{
"id": "legacy-44",
"level": "new",
"position": "44",
"title": "Sevvend Clubstep",
"url": "https://youtu.be/TvA8EJTJFCc?si=Rm9HyExGYpeGn4qs"
},
{
"id": "legacy-45",
"level": "new",
"position": "45",
"title": "Edge Of Destiny",
"url": "https://youtu.be/rUphe3H59yU?si=dZ8LgDyqif1ET_k1"
},
{
"id": "legacy-46",
"level": "new",
"position": "46",
"title": "The Halucination",
"url": "https://youtu.be/tYbXsNkO9HE?si=b8hmXS98BAjDlPzF"
},
{
"id": "legacy-47",
"level": "new",
"position": "47",
"title": "CONVULSION",
"url": "https://youtu.be/qeRKuyU3eGI?si=qJN026t6RcGjU3iW"
},
{
"id": "legacy-48",
"level": "new",
"position": "48",
"title": "Solar Flare",
"url": "https://youtu.be/eHQNgty8ypY?si=ZdUIODdeDYLAXeHI"
},
{
"id": "legacy-49",
"level": "new",
"position": "49",
"title": "LIMBO",
"url": "https://youtu.be/kXYMbaMVOZg?si=fWm4ngeSxBhFZadA"
},
{
"id": "legacy-50",
"level": "new",
"position": "50",
"title": "The Catacombs",
"url": "https://youtu.be/T9-75lfKVQg?si=zEH6vP1617n5666I"
}
]
+1
View File
@@ -11,6 +11,7 @@
<h1>Error Pages</h1>
<nav>
<a href="index.html">Home</a>
<a href="manage.html">Manage</a>
<a href="lists.html">Lists</a>
<a href="players.html">Players</a>
<a href="rules.html">Rules</a>
+2 -1
View File
@@ -10,11 +10,11 @@
<header>
<h1>GD fedl</h1>
<nav>
<a href="manage.html">Manage</a>
<a href="players.html">Players</a>
<a href="lists.html">Lists</a>
<a href="rules.html">Rules</a>
<a href="roulette.html">Roulette</a>
<a href="errors.html">Errors</a>
</nav>
</header>
<main>
@@ -26,6 +26,7 @@
<div class="panel" style="flex:1">
<h3>Quick Links</h3>
<a class="btn" href="lists.html">View Lists</a>
<a class="btn" href="manage.html" style="margin-left:8px">Manage</a>
<a class="btn" href="players.html" style="margin-left:8px">Players</a>
<a class="btn" href="rules.html" style="margin-left:8px">Rules</a>
<a class="btn" href="roulette.html" style="margin-left:8px">Roulette</a>
+1
View File
@@ -11,6 +11,7 @@
<h1>fedl</h1>
<nav>
<a href="index.html">Home</a>
<a href="manage.html">Manage</a>
<a href="players.html">Players</a>
<a href="rules.html">Rules</a>
<a href="roulette.html">Roulette</a>
+89
View File
@@ -0,0 +1,89 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Manage List - GD fedl</title>
<link rel="stylesheet" href="styles.css">
</head>
<body data-page="manage">
<header>
<h1>Manage FEDL</h1>
<nav>
<a href="index.html">Home</a>
<a href="lists.html">Lists</a>
<a href="players.html">Players</a>
<a href="rules.html">Rules</a>
<a href="roulette.html">Roulette</a>
</nav>
</header>
<main>
<section class="panel manage-hero">
<p class="rules-kicker">Node Manager</p>
<h2>Edit the list without touching raw text files</h2>
<p class="muted small">This page saves through the Node server API and keeps both <code>data.json</code> and <code>data.txt</code> in sync.</p>
</section>
<section class="panel manage-grid">
<div>
<h2 id="manage-form-title">Add Entry</h2>
<form id="level-form" class="manage-form">
<input type="hidden" id="level-id" />
<label>
Category
<input id="level-category" type="text" value="new" placeholder="new" />
</label>
<label>
Position
<input id="level-position" type="number" min="1" placeholder="1" required />
</label>
<label>
Title
<input id="level-title" type="text" placeholder="Level title" required />
</label>
<label>
Video URL
<input id="level-url" type="url" placeholder="https://youtube.com/watch?v=example" />
</label>
<div class="manage-actions">
<button class="btn" type="submit" id="level-submit">Save Entry</button>
<button class="btn ghost-btn" type="button" id="level-reset">Clear</button>
</div>
</form>
</div>
<div>
<h2>Quick Notes</h2>
<ul class="rules-list">
<li>Entries are stored by the server in JSON for easier editing and API access.</li>
<li>The legacy text format is still rewritten automatically for compatibility.</li>
<li>Sorting on the public list still follows the numeric position field.</li>
</ul>
<p class="manage-status muted" id="manage-status">Loading current entries from the server.</p>
</div>
</section>
<section class="panel">
<div class="search-row manage-search-row">
<h2>Current Entries</h2>
<input id="manage-search" type="text" placeholder="Search by title or category..." />
</div>
<div class="table-wrap">
<table class="levels-table">
<thead>
<tr>
<th style="width:100px">#</th>
<th style="width:140px">Category</th>
<th>Title</th>
<th style="width:220px">Video</th>
<th style="width:180px">Actions</th>
</tr>
</thead>
<tbody id="manage-list"></tbody>
</table>
</div>
</section>
</main>
<script src="app.js"></script>
</body>
</html>
+9
View File
@@ -0,0 +1,9 @@
{
"name": "gd-fedl",
"version": "1.0.0",
"private": true,
"description": "Node-powered FEDL list manager",
"scripts": {
"start": "node server.js"
}
}
+1
View File
@@ -11,6 +11,7 @@
<h1>Players</h1>
<nav>
<a href="index.html">Home</a>
<a href="manage.html">Manage</a>
<a href="lists.html">Lists</a>
<a href="rules.html">Rules</a>
<a href="roulette.html">Roulette</a>
+1
View File
@@ -11,6 +11,7 @@
<h1>Demon Roulette</h1>
<nav>
<a href="index.html">Home</a>
<a href="manage.html">Manage</a>
<a href="lists.html">Lists</a>
<a href="players.html">Players</a>
<a href="rules.html">Rules</a>
+1
View File
@@ -11,6 +11,7 @@
<h1>FEDL Rules</h1>
<nav>
<a href="index.html">Home</a>
<a href="manage.html">Manage</a>
<a href="lists.html">Lists</a>
<a href="players.html">Players</a>
<a href="roulette.html">Roulette</a>
+207
View File
@@ -0,0 +1,207 @@
const http = require('http');
const fs = require('fs');
const path = require('path');
const { URL } = require('url');
const HOST = process.env.HOST || '127.0.0.1';
const PORT = Number(process.env.PORT) || 8000;
const ROOT = __dirname;
const DATA_TXT_PATH = path.join(ROOT, 'data.txt');
const DATA_JSON_PATH = path.join(ROOT, 'data.json');
const MIME_TYPES = {
'.css': 'text/css; charset=utf-8',
'.html': 'text/html; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
'.txt': 'text/plain; charset=utf-8'
};
function sendJson(res, statusCode, payload) {
res.writeHead(statusCode, {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store'
});
res.end(JSON.stringify(payload, null, 2));
}
function sendText(res, statusCode, text, contentType = 'text/plain; charset=utf-8') {
res.writeHead(statusCode, {
'Content-Type': contentType,
'Cache-Control': 'no-store'
});
res.end(text);
}
function readBody(req) {
return new Promise((resolve, reject) => {
let body = '';
req.on('data', chunk => {
body += chunk;
if (body.length > 1_000_000) {
reject(new Error('Request body too large.'));
req.destroy();
}
});
req.on('end', () => resolve(body));
req.on('error', reject);
});
}
function normalizeItem(item, fallbackId) {
return {
id: String(item.id || fallbackId || Date.now()),
level: String(item.level || 'new').trim() || 'new',
position: String(item.position || '').trim(),
title: String(item.title || '').trim(),
url: String(item.url || '').trim()
};
}
function parseTxtData(txt) {
return txt
.split(/\r?\n/)
.map(line => line.trim())
.filter(Boolean)
.map((line, index) => {
const parts = line.split('|').map(part => part.trim());
return normalizeItem({
level: parts[0],
position: parts[1],
title: parts[2],
url: parts[3]
}, `legacy-${index + 1}`);
});
}
function serializeTxtData(items) {
return items
.map(item => [item.level, item.position, item.title, item.url].join('|'))
.join('\n') + '\n';
}
function loadItems() {
if (fs.existsSync(DATA_JSON_PATH)) {
const raw = fs.readFileSync(DATA_JSON_PATH, 'utf8');
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.map((item, index) => normalizeItem(item, `json-${index + 1}`));
}
if (!fs.existsSync(DATA_TXT_PATH)) return [];
const txt = fs.readFileSync(DATA_TXT_PATH, 'utf8');
const items = parseTxtData(txt);
saveItems(items);
return items;
}
function saveItems(items) {
const normalized = items.map((item, index) => normalizeItem(item, `item-${index + 1}`));
fs.writeFileSync(DATA_JSON_PATH, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8');
fs.writeFileSync(DATA_TXT_PATH, serializeTxtData(normalized), 'utf8');
return normalized;
}
function validateItem(item) {
if (!item.title) return 'Title is required.';
if (!item.position) return 'Position is required.';
if (!/^\d+$/.test(String(item.position))) return 'Position must be numeric.';
return null;
}
function getStaticPath(requestPath) {
const cleanPath = requestPath === '/' ? '/index.html' : requestPath;
const safePath = path.normalize(cleanPath).replace(/^(\.\.[/\\])+/, '').replace(/^[/\\]+/, '');
return path.join(ROOT, safePath);
}
function serveStatic(reqPath, res) {
const filePath = getStaticPath(reqPath);
if (!filePath.startsWith(ROOT)) {
sendText(res, 403, 'Forbidden');
return;
}
fs.readFile(filePath, (err, content) => {
if (err) {
const fallback404 = path.join(ROOT, '404.html');
fs.readFile(fallback404, (fallbackErr, fallbackContent) => {
if (fallbackErr) {
sendText(res, 404, 'Not Found');
return;
}
sendText(res, 404, fallbackContent, 'text/html; charset=utf-8');
});
return;
}
const ext = path.extname(filePath).toLowerCase();
sendText(res, 200, content, MIME_TYPES[ext] || 'application/octet-stream');
});
}
function sortItems(items) {
return [...items].sort((a, b) => Number(a.position) - Number(b.position));
}
loadItems();
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
const pathname = url.pathname;
if (pathname === '/api/levels' && req.method === 'GET') {
return sendJson(res, 200, { items: sortItems(loadItems()) });
}
if (pathname === '/api/levels' && req.method === 'POST') {
try {
const body = await readBody(req);
const payload = normalizeItem(JSON.parse(body), `item-${Date.now()}`);
const error = validateItem(payload);
if (error) return sendJson(res, 400, { error });
const items = loadItems();
items.push(payload);
return sendJson(res, 201, { item: payload, items: sortItems(saveItems(items)) });
} catch (error) {
return sendJson(res, 400, { error: 'Invalid JSON request body.' });
}
}
if (pathname.startsWith('/api/levels/') && (req.method === 'PUT' || req.method === 'DELETE')) {
const id = decodeURIComponent(pathname.slice('/api/levels/'.length));
const items = loadItems();
const index = items.findIndex(item => item.id === id);
if (index === -1) {
return sendJson(res, 404, { error: 'Level not found.' });
}
if (req.method === 'DELETE') {
items.splice(index, 1);
return sendJson(res, 200, { items: sortItems(saveItems(items)) });
}
try {
const body = await readBody(req);
const payload = normalizeItem({ ...items[index], ...JSON.parse(body), id }, id);
const error = validateItem(payload);
if (error) return sendJson(res, 400, { error });
items[index] = payload;
return sendJson(res, 200, { item: payload, items: sortItems(saveItems(items)) });
} catch (error) {
return sendJson(res, 400, { error: 'Invalid JSON request body.' });
}
}
if (pathname === '/api/health') {
return sendJson(res, 200, { ok: true });
}
return serveStatic(pathname, res);
});
server.listen(PORT, HOST, () => {
console.log(`GD FEDL server running at http://localhost:${PORT}`);
});
+15
View File
@@ -97,5 +97,20 @@ linear-gradient(180deg,rgba(8,18,32,0.98),rgba(7,19,38,0.94))}
.status-card{display:block;padding:18px;border-radius:14px;background:linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02));border:1px solid rgba(255,255,255,0.06);text-decoration:none;color:var(--text)}
.status-card strong{display:block;margin-bottom:6px;font-size:1.65rem}
.status-card span{display:block;color:var(--muted);line-height:1.5}
.search-row input[type=number],.search-row input[type=url],.search-row input:not([type]){flex:1;padding:10px 12px;border-radius:8px;border:1px solid rgba(255,255,255,0.04);background:transparent;color:var(--text)}
.btn + .btn{margin-left:8px}
.manage-hero h2{margin:0 0 8px 0;font-size:2rem}
.manage-grid{display:grid;grid-template-columns:minmax(320px,1.05fr) minmax(260px,0.95fr);gap:22px}
.manage-form{display:grid;gap:14px}
.manage-form label{display:grid;gap:8px;font-weight:600;color:#eaf3fe}
.manage-form input{padding:10px 12px;border-radius:8px;border:1px solid rgba(255,255,255,0.08);background:rgba(255,255,255,0.02);color:var(--text)}
.manage-actions{display:flex;flex-wrap:wrap;gap:10px}
.manage-actions .btn + .btn{margin-left:0}
.manage-status{margin-top:18px;line-height:1.6}
.manage-search-row{margin-bottom:0}
.table-actions{display:flex;flex-wrap:wrap;gap:8px}
.table-actions .btn{margin-left:0}
.table-link{color:var(--accent);text-decoration:none}
@media(max-width:900px){.layout{flex-direction:column}.sidebar{width:100%}.video-modal iframe{height:320px}.roulette-head{flex-direction:column;align-items:flex-start}.discord-panel{flex-direction:column;align-items:flex-start;padding:22px 20px}.discord-panel h2{font-size:1.55rem}.discord-actions{width:100%;justify-content:flex-start}.rules-view header{padding:16px 18px}.rules-view header h1{font-size:1.55rem}.rules-page{padding:24px 20px}.rules-page h2{font-size:1.65rem}}
@media(max-width:900px){.error-grid{grid-template-columns:1fr}.error-copy,.error-side{padding:24px 20px}.error-copy h2{font-size:2.5rem}}
@media(max-width:900px){.manage-grid{grid-template-columns:1fr}}