test 1
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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=>({"&":"&","<":"<",">":">","\"":""","'":"'"})[c])}
|
||||
})();
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "gd-fedl",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Node-powered FEDL list manager",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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}}
|
||||
|
||||
Reference in New Issue
Block a user