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:
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
+139
-21
@@ -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,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
@@ -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}}
|
||||
|
||||
Reference in New Issue
Block a user