Add run percent and approved-runs modal

Introduce a percent field for runs and surface approved runs in the video modal. UI: add a numeric Percent input to the run submission form and show percent in submission lists and admin detail view (default 100%). Video modal: refactor openVideo to accept an item, add renderApprovedRunsForLevel to load and display approved runs for the same level with player, percent and video link, plus related modal HTML. Server: normalize and validate percent on run create/update (required), returning 400 when missing. Styles: add CSS for modal runs layout and responsive tweaks.
This commit is contained in:
2026-04-01 00:11:13 -05:00
parent cdc65ec881
commit 52531abcc7
4 changed files with 59 additions and 7 deletions
+41 -3
View File
@@ -406,14 +406,40 @@
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';
a.addEventListener('click', (e)=>{e.preventDefault(); openVideo(it.url)});
a.addEventListener('click', (e)=>{e.preventDefault(); openVideo(it)});
tdAct.appendChild(a);
tr.appendChild(tdNum); tr.appendChild(tdTitle); tr.appendChild(tdAct);
tbody.appendChild(tr);
});
}
function openVideo(url){
function renderApprovedRunsForLevel(item, hostEl){
if(!hostEl) return;
hostEl.innerHTML = '<p class="muted">Loading approved runs...</p>';
loadRuns().then(runs=>{
const approvedRuns = runs.filter(run=>{
return String(run.status || '').toLowerCase() === 'approved'
&& String(run.levelTitle || '').toLowerCase() === String(item.title || '').toLowerCase();
});
if(!approvedRuns.length){
hostEl.innerHTML = '<p class="muted">No approved runs have been linked to this level yet.</p>';
return;
}
hostEl.innerHTML = approvedRuns.map(run=>`
<article class="modal-run-card">
<strong>${escapeHtml(run.playerName || 'Unknown player')}</strong>
<span>${escapeHtml(run.percent || '100')}%</span>
<a class="text-link" href="${escapeAttr(run.videoUrl || '#')}" target="_blank" rel="noopener noreferrer">Open run video</a>
</article>
`).join('');
}).catch(err=>{
console.error(err);
hostEl.innerHTML = '<p class="muted">Could not load approved runs for this level.</p>';
});
}
function openVideo(item){
const url = item && item.url;
if(!url) return; const id = extractYouTubeID(url);
if(!id){ window.open(url,'_blank'); return }
let modal = document.querySelector('.video-modal');
@@ -424,8 +450,18 @@
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);
const runsWrap = document.createElement('div'); runsWrap.className = 'modal-runs-wrap';
runsWrap.innerHTML = `
<div class="modal-runs-head">
<strong>Approved runs</strong>
<span class="muted">Player, percent, and linked video</span>
</div>
<div class="modal-runs-list"></div>
`;
inner.appendChild(runsWrap);
}
modal.querySelector('iframe').src = `https://www.youtube.com/embed/${id}`;
renderApprovedRunsForLevel(item, modal.querySelector('.modal-runs-list'));
modal.style.display = 'flex';
}
@@ -681,6 +717,7 @@
<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>Percent:</strong> ${escapeHtml(run.percent || '100')}%</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>
@@ -971,7 +1008,7 @@
<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 class="submission-meta">By ${escapeHtml(run.playerName || 'Unknown')}${escapeHtml(run.percent || '100')}% • ${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>
@@ -1008,6 +1045,7 @@
playerName: qs('run-player-name').value.trim(),
levelTitle: qs('run-level-title').value.trim(),
videoUrl: qs('run-video-url').value.trim(),
percent: qs('run-percent').value.trim(),
rawFootageUrl: qs('run-raw-footage-url').value.trim(),
notes: qs('run-notes').value.trim()
};
+5
View File
@@ -49,6 +49,11 @@
<input id="run-video-url" name="videoUrl" type="url" placeholder="https://youtu.be/..." required />
</label>
<label class="control-block" for="run-percent">
<span>Percent</span>
<input id="run-percent" name="percent" type="number" min="1" max="100" placeholder="100" 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" />
+3 -2
View File
@@ -113,6 +113,7 @@ function normalizeRun(payload, existingRun) {
playerName: String(payload.playerName || existingRun?.playerName || '').trim(),
levelTitle: String(payload.levelTitle || existingRun?.levelTitle || '').trim(),
videoUrl: String(payload.videoUrl || existingRun?.videoUrl || '').trim(),
percent: String(payload.percent || existingRun?.percent || '100').trim(),
rawFootageUrl: String(payload.rawFootageUrl || existingRun?.rawFootageUrl || '').trim(),
notes: String(payload.notes || existingRun?.notes || '').trim(),
status: String(payload.status || existingRun?.status || 'pending').trim().toLowerCase(),
@@ -232,8 +233,8 @@ const server = http.createServer((req, res) => {
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' });
if (!nextRun.playerName || !nextRun.levelTitle || !nextRun.videoUrl || !nextRun.percent) {
sendJson(res, 400, { error: 'playerName, levelTitle, videoUrl, and percent are required' });
return;
}
const runs = readRuns();
+10 -2
View File
@@ -158,8 +158,16 @@ table.levels-table .btn{padding:9px 14px}
@keyframes bounceDot{0%,80%,100%{transform:translateY(0);opacity:.5}40%{transform:translateY(-12px);opacity:1}}
@keyframes drift{0%,100%{transform:translate3d(0,0,0)}50%{transform:translate3d(20px,-18px,0)}}
.video-modal{position:fixed;inset:0;background:rgba(0,0,0,0.7);display:flex;align-items:center;justify-content:center;z-index:1200}
.video-modal .inner{width:90%;max-width:960px;background:var(--card);padding:12px;border-radius:8px}
.video-modal .inner{width:90%;max-width:960px;max-height:88vh;overflow:auto;background:var(--card);padding:12px;border-radius:8px}
.video-modal iframe{width:100%;height:540px;border:0;border-radius:6px}
.modal-runs-wrap{margin-top:16px;padding:16px;border-radius:16px;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08)}
.modal-runs-head{display:flex;align-items:end;justify-content:space-between;gap:12px;margin-bottom:12px}
.modal-runs-list{display:grid;gap:10px;max-height:240px;overflow:auto;padding-right:4px}
.modal-run-card{display:grid;grid-template-columns:minmax(0,1fr) auto auto;gap:12px;align-items:center;padding:12px 14px;border-radius:14px;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.06)}
.modal-run-card strong,.modal-run-card span,.modal-run-card a{align-self:center;line-height:1.2}
.modal-run-card strong{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.modal-run-card span{color:#dce8f5;font-weight:700;white-space:nowrap}
.modal-run-card a{margin:0;white-space:nowrap}
@media(max-width:900px){.layout{flex-direction:column}.sidebar{width:100%}.video-modal iframe{height:320px}.wip-hero{grid-template-columns:1fr;min-height:auto}.wip-copy{padding:28px 22px}.wip-status-grid{grid-template-columns:1fr}.coming-panel{padding:34px 22px}.coming-shell{min-height:auto;padding:30px 0}}
/* small helpers reused */
@@ -267,7 +275,7 @@ 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}
@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){.layout{flex-direction:column}.sidebar{width:100%}.sidebar .panel{height:auto}.video-modal iframe{height:320px}.modal-runs-head,.modal-run-card{grid-template-columns:1fr}.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-overview-panel,.admin-toolbar{flex-direction:column;align-items:stretch}.admin-actions{flex-direction:column;align-items:stretch}.admin-password-field input,.admin-actions input[type=text],.admin-actions input[type=password]{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}.admin-login-screen{padding:18px}.admin-login-card{padding:26px 22px}}
@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}}