Add run submission & admin moderation

Add a live run submission workflow and admin moderation UI.

- Add run.html: new page to submit runs (video, raw footage, notes) and show recent submissions.
- Update admelist.html: admin overview plus run queue table for reviewing/approving/rejecting/deleting submissions.
- Update app.js: client-side support for runs (load/refresh/caching, SSE "runs-update", admin review actions, run submission handling, and graceful fallback when no server is available).
- Server changes: add server/runs.json, implement /api/runs (GET, POST) and /api/runs/:id (PUT, DELETE) in server/server.js, ensure runs file, emit runs-update events, broaden CORS, set BASE path (/fedl) and default port 8090.
- Add styles for admin overview, run submission UI and submission cards in styles.css.
- Update README with run/admin pages and example URLs, adjust start-server.command to use PORT=8090, and minor note in LINUX_SERVICE_SETUP.txt.

Notes: live submission/review features require running the Node server; client falls back to static behavior when served from file:// or when server is unavailable.
This commit is contained in:
2026-03-31 21:24:50 -05:00
parent 00baa8b204
commit 90310fd671
14 changed files with 691 additions and 44 deletions
+14 -5
View File
@@ -6,19 +6,24 @@ It includes:
- a home page
- a main list page
- a run submission page
- a players page
- a rules page
- an admin panel for the list and submitted runs
## Project Files
- `index.html` - home page
- `lists.html` - main live list view
- `run.html` - live run submission page
- `players.html` - player manager
- `rules.html` - submission rules and mod guidelines
- `admelist.html` - admin panel for list edits and run review
- `app.js` - client-side logic
- `styles.css` - site styling
- `data.txt` - static fallback list data
- `server/data.txt` - live server list data
- `server/runs.json` - live run submission queue
- `server/server.js` - Node.js server for the live list
- `start-server.command` - one-click launcher for macOS
- `server/LINUX_SERVICE_SETUP.txt` - Linux `systemd` setup notes
@@ -41,6 +46,8 @@ new|1|Flamewall|https://youtu.be/x4Io4zkWVRw
- `lists.html` is the main live list page.
- `roulette.html` uses the same live server-backed list.
- `run.html` submits runs into the live moderation queue.
- `admelist.html` lets you edit the list and review submissions.
## Fallback Order
@@ -77,14 +84,15 @@ node server/server.js
Or to allow other devices on your network to connect:
```bash
HOST=0.0.0.0 PORT=3000 node server/server.js
HOST=0.0.0.0 PORT=8090 node server/server.js
```
Then open:
```txt
http://localhost:3000/lists.html
http://localhost:3000/roulette.html
http://localhost:8090/fedl/lists.html
http://localhost:8090/fedl/run.html
http://localhost:8090/fedl/admelist.html
```
On macOS you can also use:
@@ -98,8 +106,8 @@ start-server.command
If you want one device to host the live list for other devices:
1. Run the server with `HOST=0.0.0.0`.
2. Open or forward port `3000`.
3. Visit `http://YOUR-IP:3000/lists.html` or `http://YOUR-IP:3000/roulette.html`.
2. Open or forward port `8090`.
3. Visit `http://YOUR-IP:8090/fedl/lists.html`, `http://YOUR-IP:8090/fedl/run.html`, or `http://YOUR-IP:8090/fedl/admelist.html`.
If you already have router port forwarding set up, point it to the machine running `server/server.js`.
@@ -114,5 +122,6 @@ For running the Node server automatically on boot on Linux, see:
- Player data is stored in the browser with `localStorage`
- The players list is local to each browser/device
- The live list data is stored in `server/data.txt`
- The live run queue is stored in `server/runs.json`
- The static fallback list data is stored in `data.txt`
- The project does not need a build step
+43 -2
View File
@@ -8,20 +8,33 @@
</head>
<body data-page="admelist">
<header>
<h1>fedl</h1>
<h1>FEDL Admin</h1>
<nav>
<a href="index.html">Home</a>
<a href="lists.html">Lists</a>
<a href="run.html">Submit Run</a>
<a href="players.html">Players</a>
<a href="rules.html">Rules</a>
<a href="roulette.html">Roulette</a>
</nav>
</header>
<main class="admin-shell">
<section class="panel admin-overview-panel">
<div>
<p class="hero-kicker">Control Center</p>
<h2>Manage the live list and incoming runs</h2>
<p class="muted">Use the first panel to edit placements and the second panel to review new run submissions.</p>
</div>
<div class="admin-overview-badges">
<span>Live list editor</span>
<span>Run moderation</span>
</div>
</section>
<section class="panel admin-panel">
<div class="admin-toolbar">
<div>
<p class="hero-kicker">Editor</p>
<p class="hero-kicker">List Editor</p>
<h2>Manage the live list</h2>
<p id="admin-status" class="muted">Loading list data...</p>
</div>
@@ -47,6 +60,34 @@
</table>
</div>
</section>
<section class="panel admin-panel">
<div class="admin-toolbar">
<div>
<p class="hero-kicker">Run Queue</p>
<h2>Review submitted runs</h2>
<p id="runs-admin-status" class="muted">Loading run submissions...</p>
</div>
<div class="admin-actions">
<input id="run-search" type="text" placeholder="Search submissions..." />
</div>
</div>
<div class="table-wrap">
<table class="levels-table admin-table">
<thead>
<tr>
<th style="width:130px">Status</th>
<th style="width:160px">Player</th>
<th>Level</th>
<th style="width:180px">Submitted</th>
<th style="width:240px">Actions</th>
</tr>
</thead>
<tbody id="run-admin-body"></tbody>
</table>
</div>
</section>
</main>
<script src="app.js"></script>
</body>
+333 -9
View File
@@ -3,14 +3,22 @@
function qs(id){return document.getElementById(id)}
const page = document.body.dataset.page;
const liveServerBase = 'https://raspberrypi-1.tail46eacb.ts.net/fedl';
const isFileProtocol = window.location.protocol === 'file:';
const runtimeBasePath = !isFileProtocol && (window.location.pathname === '/fedl' || window.location.pathname.startsWith('/fedl/'))
? '/fedl'
: '';
const liveServerBase = isFileProtocol ? '' : `${window.location.origin}${runtimeBasePath}`;
const canUseLiveServer = !isFileProtocol;
const liveApiUrl = `${liveServerBase}/api/list`;
const liveRunsUrl = `${liveServerBase}/api/runs`;
const liveEventsUrl = `${liveServerBase}/events`;
const liveDataFileUrl = `${liveServerBase}/server/data.txt`;
let cachedItems = null;
let cachedRuns = null;
let cachedLevelMeta = null;
let liveBound = false;
let liveHandlers = [];
let runsHandlers = [];
// Storage helpers
function read(key, fallback){
@@ -52,6 +60,15 @@
function loadItems(){
if(cachedItems) return Promise.resolve(cachedItems);
if(!canUseLiveServer){
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;
});
}
return fetch(liveApiUrl, {cache:'no-store'}).then(r=>{
if(!r.ok) throw new Error('API unavailable');
const contentType = (r.headers.get('content-type') || '').toLowerCase();
@@ -85,6 +102,25 @@
cachedItems = null;
}
function loadRuns(){
if(cachedRuns) return Promise.resolve(cachedRuns);
if(!canUseLiveServer){
cachedRuns = [];
return Promise.resolve(cachedRuns);
}
return fetch(liveRunsUrl, {cache:'no-store'}).then(r=>{
if(!r.ok) throw new Error('Runs API unavailable');
return r.json();
}).then(data=>{
cachedRuns = Array.isArray(data.items) ? data.items : [];
return cachedRuns;
});
}
function clearRunsCache(){
cachedRuns = null;
}
function onLiveUpdate(handler){
liveHandlers.push(handler);
}
@@ -93,6 +129,14 @@
liveHandlers.forEach(handler=>handler(items));
}
function onRunsUpdate(handler){
runsHandlers.push(handler);
}
function notifyRunsUpdate(runs){
runsHandlers.forEach(handler=>handler(runs));
}
function refreshItems(){
clearItemsCache();
return loadItems().then(items=>{
@@ -101,13 +145,24 @@
});
}
function refreshRuns(){
clearRunsCache();
return loadRuns().then(runs=>{
notifyRunsUpdate(runs);
return runs;
});
}
function bindLiveUpdates(){
if(liveBound || typeof window.EventSource === 'undefined') return;
if(liveBound || !canUseLiveServer || typeof window.EventSource === 'undefined') return;
liveBound = true;
const source = new EventSource(liveEventsUrl);
source.addEventListener('list-update', ()=>{
refreshItems().catch(err=>console.error(err));
});
source.addEventListener('runs-update', ()=>{
refreshRuns().catch(err=>console.error(err));
});
source.onerror = function(){
source.close();
liveBound = false;
@@ -401,11 +456,15 @@
if(page==='admelist'){
const statusEl = qs('admin-status');
const tbody = qs('admin-list-body');
const listTbody = qs('admin-list-body');
const addBtn = qs('add-row');
const saveBtn = qs('save-list');
const searchEl = qs('admin-search');
const runsStatusEl = qs('runs-admin-status');
const runsTbody = qs('run-admin-body');
const runSearchEl = qs('run-search');
let items = [];
let runs = [];
function setStatus(message, isError){
if(!statusEl) return;
@@ -413,6 +472,12 @@
statusEl.classList.toggle('error-text', !!isError);
}
function setRunsStatus(message, isError){
if(!runsStatusEl) return;
runsStatusEl.textContent = message;
runsStatusEl.classList.toggle('error-text', !!isError);
}
function filteredItems(){
const query = (searchEl && searchEl.value || '').trim().toLowerCase();
if(!query) return items;
@@ -452,7 +517,7 @@
function renderAdminTable(){
const rows = filteredItems();
tbody.innerHTML = '';
listTbody.innerHTML = '';
rows.forEach(item=>{
const actualIndex = items.indexOf(item);
const tr = document.createElement('tr');
@@ -463,16 +528,89 @@
<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);
listTbody.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);
listTbody.appendChild(tr);
}
}
function filteredRuns(){
const query = (runSearchEl && runSearchEl.value || '').trim().toLowerCase();
if(!query) return runs;
return runs.filter(run=>{
return [
run.status,
run.playerName,
run.levelTitle,
run.videoUrl,
run.rawFootageUrl,
run.notes,
run.reviewNotes
].some(value=>String(value || '').toLowerCase().includes(query));
});
}
function formatDate(value){
if(!value) return 'Unknown';
const date = new Date(value);
if(Number.isNaN(date.getTime())) return 'Unknown';
return date.toLocaleString();
}
function renderRunsTable(){
const rows = filteredRuns();
runsTbody.innerHTML = '';
rows.forEach(run=>{
const tr = document.createElement('tr');
tr.innerHTML = `
<td><span class="status-pill status-${escapeAttr(run.status || 'pending')}">${escapeHtml(run.status || 'pending')}</span></td>
<td><strong>${escapeHtml(run.playerName || 'Unknown')}</strong></td>
<td>
<div class="run-admin-cell">
<strong>${escapeHtml(run.levelTitle || 'Untitled')}</strong>
<span class="muted small">${escapeHtml(run.notes || 'No submission notes.')}</span>
</div>
</td>
<td>${escapeHtml(formatDate(run.submittedAt))}</td>
<td>
<div class="run-admin-actions">
<a class="btn ghost-btn small-btn" href="${escapeAttr(run.videoUrl || '#')}" target="_blank" rel="noopener noreferrer">Video</a>
<button type="button" class="btn ghost-btn small-btn" data-run-action="approved" data-run-id="${escapeAttr(run.id)}">Approve</button>
<button type="button" class="btn ghost-btn small-btn" data-run-action="rejected" data-run-id="${escapeAttr(run.id)}">Reject</button>
<button type="button" class="btn danger-btn small-btn" data-run-delete="${escapeAttr(run.id)}">Delete</button>
</div>
</td>
`;
runsTbody.appendChild(tr);
const detailRow = document.createElement('tr');
detailRow.className = 'run-admin-detail-row';
detailRow.innerHTML = `
<td colspan="5">
<div class="run-admin-detail">
<span><strong>Raw:</strong> ${run.rawFootageUrl ? `<a href="${escapeAttr(run.rawFootageUrl)}" target="_blank" rel="noopener noreferrer">${escapeHtml(run.rawFootageUrl)}</a>` : 'None provided'}</span>
<span><strong>Reviewed by:</strong> ${escapeHtml(run.reviewedBy || 'Unassigned')}</span>
<span><strong>Review notes:</strong> ${escapeHtml(run.reviewNotes || 'No review notes yet.')}</span>
</div>
</td>
`;
runsTbody.appendChild(detailRow);
});
if(!rows.length){
const tr = document.createElement('tr');
tr.innerHTML = '<td colspan="5" class="muted">No run submissions match your search.</td>';
runsTbody.appendChild(tr);
}
}
function saveItems(){
if(!canUseLiveServer){
setStatus('Start the Node server to save the live list.', true);
return Promise.resolve();
}
const hasUnplacedDraft = items.some(item=>{
const hasContent = String(item.title || '').trim() || String(item.url || '').trim() || String(item.level || '').trim();
return item._isDraft && hasContent;
@@ -523,7 +661,65 @@
});
}
tbody.addEventListener('input', function(event){
function loadRunsAdmin(){
loadRuns().then(loadedRuns=>{
runs = loadedRuns.slice().sort((a,b)=>new Date(b.submittedAt) - new Date(a.submittedAt));
renderRunsTable();
setRunsStatus('Connected to live run submissions.');
}).catch(err=>{
console.error(err);
setRunsStatus('Could not load run submissions.', true);
});
}
function updateRunStatus(runId, status){
if(!canUseLiveServer){
setRunsStatus('Start the Node server to review submissions.', true);
return;
}
const run = runs.find(entry=>entry.id === runId);
if(!run) return;
const reviewNotes = window.prompt(`Review notes for ${run.levelTitle} (${status})`, run.reviewNotes || '');
if(reviewNotes === null) return;
fetch(`${liveRunsUrl}/${encodeURIComponent(runId)}`, {
method:'PUT',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({
...run,
status,
reviewNotes,
reviewedBy:'FEDL Admin'
})
}).then(r=>{
if(!r.ok) throw new Error('Run update failed');
clearRunsCache();
setRunsStatus(`Run marked ${status}.`);
return refreshRuns();
}).catch(err=>{
console.error(err);
setRunsStatus('Could not update that run.', true);
});
}
function deleteRun(runId){
if(!canUseLiveServer){
setRunsStatus('Start the Node server to delete submissions.', true);
return;
}
fetch(`${liveRunsUrl}/${encodeURIComponent(runId)}`, {
method:'DELETE'
}).then(r=>{
if(!r.ok) throw new Error('Run delete failed');
clearRunsCache();
setRunsStatus('Run removed from the queue.');
return refreshRuns();
}).catch(err=>{
console.error(err);
setRunsStatus('Could not delete that run.', true);
});
}
listTbody.addEventListener('input', function(event){
const target = event.target;
const field = target.getAttribute('data-field');
const index = Number(target.getAttribute('data-index'));
@@ -533,7 +729,7 @@
setStatus('Unsaved changes');
});
tbody.addEventListener('focusout', function(event){
listTbody.addEventListener('focusout', function(event){
const target = event.target;
if(!(target instanceof HTMLElement)) return;
const field = target.getAttribute('data-field');
@@ -544,7 +740,7 @@
setStatus('Unsaved changes');
});
tbody.addEventListener('click', function(event){
listTbody.addEventListener('click', function(event){
const deleteButton = event.target.closest('[data-delete]');
if(!deleteButton) return;
const deleteIndex = deleteButton.getAttribute('data-delete');
@@ -557,6 +753,24 @@
setStatus('Row removed. Save when ready.');
});
runsTbody.addEventListener('click', function(event){
const actionButton = event.target.closest('[data-run-action]');
if(actionButton){
updateRunStatus(
actionButton.getAttribute('data-run-id'),
actionButton.getAttribute('data-run-action')
);
return;
}
const deleteButton = event.target.closest('[data-run-delete]');
if(deleteButton){
const runId = deleteButton.getAttribute('data-run-delete');
if(runId && window.confirm('Delete this run submission?')){
deleteRun(runId);
}
}
});
addBtn.addEventListener('click', function(){
items.unshift({level:'new', position:'', title:'', url:'', _isDraft:true});
normalizePositions();
@@ -571,6 +785,9 @@
if(searchEl){
searchEl.addEventListener('input', renderAdminTable);
}
if(runSearchEl){
runSearchEl.addEventListener('input', renderRunsTable);
}
bindLiveUpdates();
onLiveUpdate(function(updatedItems){
@@ -585,8 +802,115 @@
renderAdminTable();
setStatus('List reloaded from live server.');
});
onRunsUpdate(function(updatedRuns){
runs = updatedRuns.slice().sort((a,b)=>new Date(b.submittedAt) - new Date(a.submittedAt));
renderRunsTable();
setRunsStatus('Run queue reloaded from the live server.');
});
loadAdmin();
loadRunsAdmin();
}
if(page==='run'){
const form = qs('run-form');
const formStatusEl = qs('run-form-status');
const listStatusEl = qs('run-list-status');
const submissionsEl = qs('run-submissions');
const levelOptionsEl = qs('run-level-options');
function setRunFormStatus(message, isError){
if(!formStatusEl) return;
formStatusEl.textContent = message;
formStatusEl.classList.toggle('error-text', !!isError);
}
function setRunListStatus(message, isError){
if(!listStatusEl) return;
listStatusEl.textContent = message;
listStatusEl.classList.toggle('error-text', !!isError);
}
function renderRunSubmissions(runs){
submissionsEl.innerHTML = '';
if(!runs.length){
submissionsEl.innerHTML = '<article class="submission-card"><strong>No runs submitted yet</strong><p>The live queue is empty right now.</p></article>';
return;
}
runs.slice(0, 8).forEach(run=>{
const card = document.createElement('article');
card.className = 'submission-card';
card.innerHTML = `
<div class="submission-card-top">
<strong>${escapeHtml(run.levelTitle || 'Untitled')}</strong>
<span class="status-pill status-${escapeAttr(run.status || 'pending')}">${escapeHtml(run.status || 'pending')}</span>
</div>
<p class="submission-meta">By ${escapeHtml(run.playerName || 'Unknown')}${escapeHtml(new Date(run.submittedAt).toLocaleString())}</p>
<p>${escapeHtml(run.reviewNotes || run.notes || 'No notes yet.')}</p>
<div class="submission-links">
<a class="text-link" href="${escapeAttr(run.videoUrl || '#')}" target="_blank" rel="noopener noreferrer">Watch run</a>
${run.rawFootageUrl ? `<a class="text-link" href="${escapeAttr(run.rawFootageUrl)}" target="_blank" rel="noopener noreferrer">Raw footage</a>` : ''}
</div>
`;
submissionsEl.appendChild(card);
});
}
function loadRunPage(){
loadItems().then(items=>{
const titles = items.map(item=>item.title).filter(Boolean);
levelOptionsEl.innerHTML = titles.map(title=>`<option value="${escapeAttr(title)}"></option>`).join('');
}).catch(err=>console.error(err));
loadRuns().then(runs=>{
const sortedRuns = runs.slice().sort((a,b)=>new Date(b.submittedAt) - new Date(a.submittedAt));
renderRunSubmissions(sortedRuns);
setRunListStatus(canUseLiveServer ? 'Live submissions are updating automatically.' : 'Run the Node server to enable live submissions.');
}).catch(err=>{
console.error(err);
renderRunSubmissions([]);
setRunListStatus('Could not load recent submissions.', true);
});
}
form.addEventListener('submit', function(event){
event.preventDefault();
if(!canUseLiveServer){
setRunFormStatus('Start the Node server before submitting runs.', true);
return;
}
const payload = {
playerName: qs('run-player-name').value.trim(),
levelTitle: qs('run-level-title').value.trim(),
videoUrl: qs('run-video-url').value.trim(),
rawFootageUrl: qs('run-raw-footage-url').value.trim(),
notes: qs('run-notes').value.trim()
};
setRunFormStatus('Sending your run to the live queue...');
fetch(liveRunsUrl, {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify(payload)
}).then(r=>{
if(!r.ok) throw new Error('Submission failed');
clearRunsCache();
form.reset();
setRunFormStatus('Run submitted. The admin panel can review it now.');
return refreshRuns();
}).catch(err=>{
console.error(err);
setRunFormStatus('Could not submit the run. Check the server and try again.', true);
});
});
bindLiveUpdates();
onRunsUpdate(function(updatedRuns){
const sortedRuns = updatedRuns.slice().sort((a,b)=>new Date(b.submittedAt) - new Date(a.submittedAt));
renderRunSubmissions(sortedRuns);
setRunListStatus('Recent submissions reloaded from the live server.');
});
loadRunPage();
}
// Utility
+7 -4
View File
@@ -12,8 +12,10 @@
<nav>
<a href="players.html">Players</a>
<a href="lists.html">Lists</a>
<a href="run.html">Submit Run</a>
<a href="rules.html">Rules</a>
<a href="roulette.html">Roulette</a>
<a href="admelist.html">Admin</a>
</nav>
</header>
<main>
@@ -25,6 +27,7 @@
<div class="hero-actions">
<a class="btn" href="lists.html">Open The List</a>
<a class="btn ghost-btn" href="run.html">Submit A Run</a>
<a class="btn ghost-btn" href="rules.html">Submission Rules</a>
<a class="btn ghost-btn" href="roulette.html">Spin Roulette</a>
</div>
@@ -75,10 +78,10 @@
</article>
<article class="panel info-panel">
<p class="status-label">Need A Challenge</p>
<h3>Roulette gives you a random demon instantly</h3>
<p class="muted">Great for practice sessions, challenge runs, or picking something painful when you cannot decide.</p>
<a class="text-link" href="roulette.html">Try roulette</a>
<p class="status-label">Send Proof</p>
<h3>The run page sends completions into the live queue</h3>
<p class="muted">Drop in your video, add raw footage and notes, and let the admin panel pick it up for review.</p>
<a class="text-link" href="run.html">Open submissions</a>
</article>
</section>
+2
View File
@@ -12,8 +12,10 @@
<nav>
<a href="index.html">Home</a>
<a href="players.html">Players</a>
<a href="run.html">Submit Run</a>
<a href="rules.html">Rules</a>
<a href="roulette.html">Roulette</a>
<a href="admelist.html">Admin</a>
</nav>
</header>
<main class="layout">
+2
View File
@@ -12,8 +12,10 @@
<nav>
<a href="index.html">Home</a>
<a href="lists.html">Lists</a>
<a href="run.html">Submit Run</a>
<a href="rules.html">Rules</a>
<a href="roulette.html">Roulette</a>
<a href="admelist.html">Admin</a>
</nav>
</header>
<main>
+2
View File
@@ -12,8 +12,10 @@
<nav>
<a href="index.html">Home</a>
<a href="lists.html">Lists</a>
<a href="run.html">Submit Run</a>
<a href="players.html">Players</a>
<a href="rules.html">Rules</a>
<a href="admelist.html">Admin</a>
</nav>
</header>
<main>
+2
View File
@@ -12,8 +12,10 @@
<nav>
<a href="index.html">Home</a>
<a href="lists.html">Lists</a>
<a href="run.html">Submit Run</a>
<a href="players.html">Players</a>
<a href="roulette.html">Roulette</a>
<a href="admelist.html">Admin</a>
</nav>
</header>
<main>
+102
View File
@@ -0,0 +1,102 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Submit Run - GD fedl</title>
<link rel="stylesheet" href="styles.css">
</head>
<body data-page="run">
<header>
<h1>Submit Run</h1>
<nav>
<a href="index.html">Home</a>
<a href="lists.html">Lists</a>
<a href="rules.html">Rules</a>
<a href="roulette.html">Roulette</a>
<a href="admelist.html">Admin</a>
</nav>
</header>
<main>
<section class="run-shell">
<section class="panel run-panel">
<div class="run-head">
<div>
<p class="hero-kicker">FEDL Submission</p>
<h2>Send in a run</h2>
<p class="muted run-intro">Submit your completion video, attach raw footage if you have it hosted, and leave notes for the moderators. Required fields are marked below.</p>
</div>
<div class="run-head-badge">
<strong>Live queue</strong>
<span>Submissions land in the admin panel instantly when the Node server is running.</span>
</div>
</div>
<form id="run-form" class="run-form">
<label class="control-block" for="run-player-name">
<span>Player Name</span>
<input id="run-player-name" name="playerName" type="text" placeholder="Your in-game name" required />
</label>
<label class="control-block" for="run-level-title">
<span>Level</span>
<input id="run-level-title" name="levelTitle" type="text" list="run-level-options" placeholder="Level title" required />
<datalist id="run-level-options"></datalist>
</label>
<label class="control-block" for="run-video-url">
<span>Completion Video URL</span>
<input id="run-video-url" name="videoUrl" type="url" placeholder="https://youtu.be/..." required />
</label>
<label class="control-block" for="run-raw-footage-url">
<span>Raw Footage URL</span>
<input id="run-raw-footage-url" name="rawFootageUrl" type="url" placeholder="Optional raw footage link" />
</label>
<label class="control-block run-notes-block" for="run-notes">
<span>Notes For Mods</span>
<textarea id="run-notes" name="notes" rows="6" placeholder="Mention handcam, custom copy info, bug fix approval, or anything the moderators should know."></textarea>
</label>
<div class="run-actions">
<button id="run-submit" type="submit" class="btn">Submit Run</button>
<p id="run-form-status" class="muted">The server needs to be running for live submissions.</p>
</div>
</form>
</section>
<aside class="panel run-side-panel">
<p class="status-label">Checklist</p>
<h2>Before you submit</h2>
<div class="run-checklist">
<article>
<strong>Completion video</strong>
<p>Make sure the completion link is public and actually shows the run you want reviewed.</p>
</article>
<article>
<strong>Raw footage</strong>
<p>Upload raw footage somewhere staff can reach it if your run needs manual review.</p>
</article>
<article>
<strong>Important notes</strong>
<p>Call out solo or 2-player variants, custom copies, LDM use, and any handcam details in the notes box.</p>
</article>
</div>
</aside>
</section>
<section class="panel submission-panel">
<div class="table-header submission-header">
<div>
<p class="status-label">Submission Queue</p>
<h3>Recent run submissions</h3>
</div>
<p id="run-list-status" class="muted">Loading recent submissions...</p>
</div>
<div id="run-submissions" class="submission-list"></div>
</section>
</main>
<script src="app.js"></script>
</body>
</html>
+1
View File
@@ -1,6 +1,7 @@
Linux system service setup
This lets the server start automatically on boot on a Linux device.
MAKE A FILE NAME data.txt TO RUN DO THIS FURST
1. Copy the project to the Linux machine.
Example path:
+1
View File
@@ -0,0 +1 @@
[]
+139 -21
View File
@@ -5,9 +5,10 @@ const { URL } = require('url');
const appRoot = path.resolve(__dirname, '..');
const dataPath = path.join(__dirname, 'data.txt');
const port = Number(process.env.PORT) || 3000;
const runsPath = path.join(__dirname, 'runs.json');
const port = Number(process.env.PORT) || 8090;
const host = process.env.HOST || '127.0.0.1';
const basePath = (process.env.BASE_PATH || '').replace(/\/$/, '');
const BASE = '/fedl';
const clients = new Set();
const contentTypes = {
@@ -43,14 +44,14 @@ function readDataText() {
return fs.readFileSync(dataPath, 'utf8');
}
function setCorsHeaders(res) {
function setCors(res) {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET,PUT,HEAD,OPTIONS');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, HEAD, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
}
function sendJson(res, statusCode, payload) {
setCorsHeaders(res);
setCors(res);
res.writeHead(statusCode, {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store'
@@ -65,25 +66,60 @@ function sendEvent(eventName, data) {
}
}
function ensureRunsFile() {
if (!fs.existsSync(runsPath)) {
fs.writeFileSync(runsPath, '[]\n', 'utf8');
}
}
function readRuns() {
ensureRunsFile();
const raw = fs.readFileSync(runsPath, 'utf8');
const parsed = JSON.parse(raw || '[]');
return Array.isArray(parsed) ? parsed : [];
}
function writeRuns(runs) {
fs.writeFileSync(runsPath, `${JSON.stringify(runs, null, 2)}\n`, 'utf8');
}
function normalizeRun(payload, existingRun) {
return {
id: existingRun && existingRun.id ? existingRun.id : `run_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`,
playerName: String(payload.playerName || existingRun?.playerName || '').trim(),
levelTitle: String(payload.levelTitle || existingRun?.levelTitle || '').trim(),
videoUrl: String(payload.videoUrl || existingRun?.videoUrl || '').trim(),
rawFootageUrl: String(payload.rawFootageUrl || existingRun?.rawFootageUrl || '').trim(),
notes: String(payload.notes || existingRun?.notes || '').trim(),
status: String(payload.status || existingRun?.status || 'pending').trim().toLowerCase(),
reviewedBy: String(payload.reviewedBy || existingRun?.reviewedBy || '').trim(),
reviewNotes: String(payload.reviewNotes || existingRun?.reviewNotes || '').trim(),
submittedAt: existingRun?.submittedAt || new Date().toISOString(),
updatedAt: new Date().toISOString()
};
}
function serveFile(reqPath, res) {
let filePath = path.join(appRoot, reqPath === '/' ? 'index.html' : reqPath.slice(1));
filePath = path.normalize(filePath);
if (!filePath.startsWith(appRoot)) {
res.writeHead(403, {'Content-Type': 'text/plain; charset=utf-8'});
setCors(res);
res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Forbidden');
return;
}
fs.readFile(filePath, (err, data) => {
if (err) {
setCorsHeaders(res);
res.writeHead(err.code === 'ENOENT' ? 404 : 500, {'Content-Type': 'text/plain; charset=utf-8'});
setCors(res);
res.writeHead(err.code === 'ENOENT' ? 404 : 500, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end(err.code === 'ENOENT' ? 'Not found' : 'Server error');
return;
}
const ext = path.extname(filePath).toLowerCase();
setCorsHeaders(res);
setCors(res);
res.writeHead(200, {
'Content-Type': contentTypes[ext] || 'application/octet-stream',
'Cache-Control': ext === '.html' ? 'no-store' : 'no-cache'
@@ -94,12 +130,14 @@ function serveFile(reqPath, res) {
const server = http.createServer((req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
const pathname = basePath && url.pathname.startsWith(basePath)
? url.pathname.slice(basePath.length) || '/'
: url.pathname;
let pathname = url.pathname;
if (pathname.startsWith(BASE)) {
pathname = pathname.slice(BASE.length) || '/';
}
if (req.method === 'OPTIONS') {
setCorsHeaders(res);
setCors(res);
res.writeHead(204);
res.end();
return;
@@ -138,23 +176,97 @@ const server = http.createServer((req, res) => {
}
if (req.method === 'GET' && pathname === '/events') {
setCorsHeaders(res);
setCors(res);
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive'
'Connection': 'keep-alive'
});
res.write('retry: 3000\n\n');
clients.add(res);
req.on('close', () => {
clients.delete(res);
req.on('close', () => clients.delete(res));
return;
}
if (req.method === 'GET' && pathname === '/api/runs') {
try {
sendJson(res, 200, { items: readRuns() });
} catch (error) {
sendJson(res, 500, { error: 'Could not read server/runs.json' });
}
return;
}
if (req.method === 'POST' && pathname === '/api/runs') {
let body = '';
req.on('data', chunk => {
body += chunk;
if (body.length > 2 * 1024 * 1024) req.destroy();
});
req.on('end', () => {
try {
const payload = JSON.parse(body || '{}');
const nextRun = normalizeRun(payload);
if (!nextRun.playerName || !nextRun.levelTitle || !nextRun.videoUrl) {
sendJson(res, 400, { error: 'playerName, levelTitle, and videoUrl are required' });
return;
}
const runs = readRuns();
runs.unshift(nextRun);
writeRuns(runs);
sendEvent('runs-update', { updatedAt: nextRun.updatedAt });
sendJson(res, 201, { ok: true, item: nextRun });
} catch (error) {
sendJson(res, 400, { error: 'Invalid run payload' });
}
});
return;
}
if ((req.method === 'PUT' || req.method === 'DELETE') && pathname.startsWith('/api/runs/')) {
const runId = pathname.slice('/api/runs/'.length);
if (!runId) {
sendJson(res, 400, { error: 'Run id is required' });
return;
}
let body = '';
req.on('data', chunk => {
body += chunk;
if (body.length > 2 * 1024 * 1024) req.destroy();
});
req.on('end', () => {
try {
const runs = readRuns();
const index = runs.findIndex(run => run.id === runId);
if (index === -1) {
sendJson(res, 404, { error: 'Run not found' });
return;
}
if (req.method === 'DELETE') {
runs.splice(index, 1);
writeRuns(runs);
sendEvent('runs-update', { updatedAt: new Date().toISOString() });
sendJson(res, 200, { ok: true });
return;
}
const payload = JSON.parse(body || '{}');
runs[index] = normalizeRun(payload, runs[index]);
writeRuns(runs);
sendEvent('runs-update', { updatedAt: runs[index].updatedAt });
sendJson(res, 200, { ok: true, item: runs[index] });
} catch (error) {
sendJson(res, 400, { error: 'Invalid run update payload' });
}
});
return;
}
if (req.method !== 'GET' && req.method !== 'HEAD') {
setCorsHeaders(res);
res.writeHead(405, {'Content-Type': 'text/plain; charset=utf-8'});
setCors(res);
res.writeHead(405, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Method not allowed');
return;
}
@@ -166,8 +278,14 @@ fs.watch(dataPath, { persistent: true }, () => {
sendEvent('list-update', { updatedAt: new Date().toISOString() });
});
ensureRunsFile();
fs.watch(runsPath, { persistent: true }, () => {
sendEvent('runs-update', { updatedAt: new Date().toISOString() });
});
server.listen(port, host, () => {
console.log(`fedl server running at http://${host}:${port}`);
console.log(`FEDL server running at http://${host}:${port}`);
console.log(`Base path: ${BASE}`);
console.log(`Using live list file: ${dataPath}`);
console.log(`Base path: ${basePath || '/'}`);
console.log(`Using runs file: ${runsPath}`);
});
+1 -1
View File
@@ -1,3 +1,3 @@
#!/bin/zsh
cd "/Users/miles/Documents/GitHub/fedl" || exit 1
HOST=0.0.0.0 PORT=3000 node server/server.js
HOST=0.0.0.0 PORT=8090 node server/server.js
+42 -2
View File
@@ -35,11 +35,20 @@ table.levels-table tr:hover td{background:rgba(255,255,255,0.01)}
.danger-btn{background:#ff7b7b;color:#2a0909}
.admin-shell{padding:18px 20px}
.admin-panel{padding:20px}
.admin-overview-panel{display:flex;align-items:center;justify-content:space-between;gap:18px;padding:24px 26px;border-radius:28px;background:linear-gradient(135deg,rgba(255,184,77,0.12),rgba(7,19,38,0.95));border:1px solid rgba(255,184,77,0.16)}
.admin-overview-panel h2{margin:0 0 8px 0;font-size:clamp(1.9rem,4vw,2.8rem)}
.admin-overview-badges{display:flex;flex-wrap:wrap;gap:10px}
.admin-overview-badges span{padding:10px 14px;border-radius:999px;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.08);color:#eef5ff;font-weight:700}
.admin-toolbar{display:flex;justify-content:space-between;gap:18px;align-items:flex-end;margin-bottom:18px}
.admin-toolbar h2{margin:0 0 8px 0}
.admin-actions{display:flex;flex-wrap:wrap;gap:10px;align-items:center}
.admin-actions input[type=text]{min-width:220px;padding:10px 12px;border-radius:10px;border:1px solid rgba(255,255,255,0.08);background:rgba(255,255,255,0.03);color:var(--text)}
.admin-table td:last-child{text-align:center}
.run-admin-cell{display:grid;gap:6px}
.run-admin-actions{display:flex;flex-wrap:wrap;gap:8px;justify-content:flex-end}
.run-admin-detail-row td{padding-top:0}
.run-admin-detail{display:grid;gap:8px;padding:0 0 14px 0;color:#dce8f5}
.run-admin-detail a{color:var(--accent)}
.hero-kicker{margin:0 0 12px 0;text-transform:uppercase;letter-spacing:.18em;font-size:.75rem;font-weight:800;color:var(--accent-warm)}
.home-hero{display:grid;grid-template-columns:minmax(0,1.25fr) minmax(300px,.85fr);gap:22px;align-items:stretch;min-height:calc(100vh - 132px)}
.home-copy,.hero-stage{position:relative;overflow:hidden}
@@ -163,6 +172,37 @@ table.levels-table .btn{padding:9px 14px}
.roulette-rule-list article{padding:16px;border-radius:18px;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.06)}
.roulette-rule-list strong{display:block;margin-bottom:8px;font-size:1rem;color:#fff}
.roulette-rule-list p{margin:0;color:#dce8f5;line-height:1.6}
.run-shell{display:grid;grid-template-columns:minmax(0,1.2fr) minmax(280px,.8fr);gap:18px;align-items:start}
.run-panel{padding:28px;border-radius:28px;background:linear-gradient(135deg,rgba(92,197,255,0.14),rgba(7,19,38,0.96));border:1px solid rgba(92,197,255,0.18)}
.run-head{display:flex;align-items:flex-start;justify-content:space-between;gap:18px;margin-bottom:20px}
.run-head h2{margin:0 0 10px 0;font-size:clamp(2rem,4vw,3rem)}
.run-intro{max-width:58ch;line-height:1.7}
.run-head-badge{max-width:240px;padding:16px 18px;border-radius:18px;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.08)}
.run-head-badge strong{display:block;margin-bottom:8px}
.run-head-badge span{display:block;color:#dce8f5;line-height:1.55;font-size:.92rem}
.run-form{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}
.run-notes-block{grid-column:1/-1}
.run-form textarea{width:100%;padding:12px 14px;border-radius:14px;border:1px solid rgba(255,255,255,0.08);background:rgba(255,255,255,0.03);color:var(--text);resize:vertical;min-height:150px}
.run-actions{grid-column:1/-1;display:flex;flex-wrap:wrap;align-items:center;gap:14px}
.run-side-panel{padding:24px 22px;border-radius:28px;background:linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02));border:1px solid rgba(255,255,255,0.08)}
.run-side-panel h2{margin:0 0 16px 0;font-size:1.8rem}
.run-checklist{display:grid;gap:14px}
.run-checklist article{padding:16px;border-radius:18px;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.06)}
.run-checklist strong{display:block;margin-bottom:8px}
.run-checklist p{margin:0;color:#dce8f5;line-height:1.6}
.submission-panel{margin-top:18px;padding:22px 22px 16px;border-radius:26px;background:linear-gradient(180deg,rgba(255,255,255,0.04),rgba(255,255,255,0.02));border:1px solid rgba(255,255,255,0.06)}
.submission-header{margin-bottom:18px}
.submission-list{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:14px}
.submission-card{padding:18px;border-radius:18px;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08)}
.submission-card-top{display:flex;align-items:center;justify-content:space-between;gap:12px}
.submission-card strong{font-size:1.05rem}
.submission-card p{margin:10px 0 0 0;color:#dce8f5;line-height:1.55}
.submission-meta{font-size:.92rem;color:var(--muted)!important}
.submission-links{display:flex;flex-wrap:wrap;gap:14px;margin-top:14px}
.status-pill{display:inline-flex;align-items:center;justify-content:center;padding:6px 10px;border-radius:999px;font-size:.76rem;font-weight:800;letter-spacing:.08em;text-transform:uppercase}
.status-pending{background:rgba(255,184,77,0.16);color:#ffd07d}
.status-approved{background:rgba(70,196,121,0.16);color:#8cf0ae}
.status-rejected{background:rgba(255,123,123,0.16);color:#ffaeae}
.discord-panel{display:flex;align-items:center;justify-content:space-between;gap:24px;margin-top:18px;padding:26px 28px;background:linear-gradient(135deg,rgba(88,101,242,0.18),rgba(7,19,38,0.95));border:1px solid rgba(88,101,242,0.22)}
.discord-copy{flex:1}
.discord-badge{display:flex;align-items:center;gap:16px;margin-bottom:14px}
@@ -216,5 +256,5 @@ linear-gradient(180deg,rgba(8,18,32,0.98),rgba(7,19,38,0.94))}
.status-card span{display:block;color:var(--muted);line-height:1.5}
@media(max-width:900px){.layout{flex-direction:column}.sidebar{width:100%}.sidebar .panel{height:auto}.video-modal iframe{height:320px}.home-hero{grid-template-columns:1fr;min-height:auto}.home-copy,.hero-stage{padding:24px 22px}.hero-stats,.home-section-grid,.spotlight-grid{grid-template-columns:1fr}.spotlight-panel{grid-template-columns:1fr;padding:24px 22px}.list-hero-panel,.table-header,.search-row,.roulette-head{flex-direction:column;align-items:flex-start}.control-select{max-width:none;width:100%}.roulette-shell{grid-template-columns:1fr}.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){.admin-toolbar{flex-direction:column;align-items:stretch}.admin-actions{flex-direction:column;align-items:stretch}.admin-actions input[type=text]{min-width:0;width:100%}}
@media(max-width:640px){header{flex-direction:column;align-items:flex-start;gap:12px}nav{display:flex;flex-wrap:wrap;gap:10px}nav a{margin-left:0}.home-copy h2{max-width:none}.hero-actions{flex-direction:column;align-items:stretch}.hero-actions .btn,.roulette-btn{width:100%;text-align:center}.list-hero-panel,.list-controls-panel,.list-table-panel,.roulette-panel,.roulette-rules-card{padding:22px 18px}.table-hint{text-align:left}}
@media(max-width:900px){.admin-overview-panel,.admin-toolbar{flex-direction:column;align-items:stretch}.admin-actions{flex-direction:column;align-items:stretch}.admin-actions input[type=text]{min-width:0;width:100%}.run-shell{grid-template-columns:1fr}.run-head{flex-direction:column;align-items:flex-start}.run-form{grid-template-columns:1fr}}
@media(max-width:640px){header{flex-direction:column;align-items:flex-start;gap:12px}nav{display:flex;flex-wrap:wrap;gap:10px}nav a{margin-left:0}.home-copy h2{max-width:none}.hero-actions{flex-direction:column;align-items:stretch}.hero-actions .btn,.roulette-btn{width:100%;text-align:center}.list-hero-panel,.list-controls-panel,.list-table-panel,.roulette-panel,.roulette-rules-card,.run-panel,.run-side-panel,.submission-panel{padding:22px 18px}.table-hint{text-align:left}.run-admin-actions{justify-content:flex-start}}