diff options
Diffstat (limited to 'web/src/screens')
| -rw-r--r-- | web/src/screens/contributeScreen.js | 49 | ||||
| -rw-r--r-- | web/src/screens/endScreen.js | 7 | ||||
| -rw-r--r-- | web/src/screens/multiplayerScreen.js | 20 | ||||
| -rw-r--r-- | web/src/screens/storeScreen.js | 8 |
4 files changed, 79 insertions, 5 deletions
diff --git a/web/src/screens/contributeScreen.js b/web/src/screens/contributeScreen.js index 4464ae2..4c47852 100644 --- a/web/src/screens/contributeScreen.js +++ b/web/src/screens/contributeScreen.js @@ -24,6 +24,9 @@ export function renderContributeScreen(root, { onBack, onShowStats, onShowPlaceh let mine = []; let queue = []; let isModerator = false; + let isAdmin = false; + let roleHolders = []; + let userSearch = []; let cleanupInner = null; function statusLabel(s) { @@ -39,6 +42,10 @@ export function renderContributeScreen(root, { onBack, onShowStats, onShowPlaceh // case rather than an error worth showing. try { queue = await api.get('/api/texts/queue'); isModerator = true; } catch { queue = []; isModerator = false; } + // Same shape: a 401 here means "not an administrator", which is the + // ordinary case for almost everyone. + try { roleHolders = await api.get('/api/admin/users'); isAdmin = true; } + catch { roleHolders = []; isAdmin = false; } } function submissionRow(s, moderating) { @@ -88,6 +95,26 @@ export function renderContributeScreen(root, { onBack, onShowStats, onShowPlaceh <div class="submission-list">${mine.map(s => submissionRow(s, false)).join('')}</div> ` : ''} + ${isAdmin ? ` + <h3>Moderators</h3> + <div class="settings-hint">Moderators review submitted passages. An administrator's own role is managed by the server, not here.</div> + <form class="account-form admin-search-form"> + <input class="account-input admin-search" type="text" placeholder="Find a user by name" autocomplete="off"> + <button class="menu-button small" type="submit">Search</button> + </form> + <div class="submission-list admin-user-list"> + ${(userSearch.length ? userSearch : roleHolders).map(u => ` + <div class="admin-user" data-username="${escapeHtml(u.username)}"> + <span class="admin-user-name">${escapeHtml(u.username)}</span> + ${u.is_admin ? '<span class="admin-user-role">admin</span>' : ''} + ${u.is_moderator && !u.is_admin ? '<span class="admin-user-role">moderator</span>' : ''} + ${u.is_bot ? '<span class="admin-user-role">bot</span>' : ''} + ${u.is_admin ? '' : `<button class="menu-button small ${u.is_moderator ? 'quiet' : ''}" data-action="set-role" data-username="${escapeHtml(u.username)}" data-moderator="${u.is_moderator ? 'false' : 'true'}">${u.is_moderator ? 'Remove' : 'Make moderator'}</button>`} + </div> + `).join('') || '<div class="stats-empty">No moderators yet. Search for a user to appoint one.</div>'} + </div> + ` : ''} + ${isModerator ? ` <h3>Review queue${queue.length ? ` (${queue.length})` : ''}</h3> ${queue.length @@ -135,6 +162,28 @@ export function renderContributeScreen(root, { onBack, onShowStats, onShowPlaceh }); } + const searchForm = root.querySelector('.admin-search-form'); + if (searchForm) { + searchForm.addEventListener('submit', async e => { + e.preventDefault(); + const q = searchForm.querySelector('.admin-search').value.trim(); + try { userSearch = q ? await api.get(`/api/admin/users?q=${encodeURIComponent(q)}`) : []; } + catch { userSearch = []; } + render(); + }); + } + root.querySelectorAll('[data-action="set-role"]').forEach(btn => { + btn.addEventListener('click', async () => { + try { + await api.post(`/api/admin/users/${encodeURIComponent(btn.dataset.username)}/role`, + { moderator: btn.dataset.moderator === 'true' }); + } catch { /* reloading below shows whatever actually happened */ } + userSearch = []; + await load(); + render(); + }); + }); + root.querySelectorAll('.submission-actions button').forEach(btn => { btn.addEventListener('click', async () => { const row = btn.closest('.submission-row'); diff --git a/web/src/screens/endScreen.js b/web/src/screens/endScreen.js index 086407c..b03c306 100644 --- a/web/src/screens/endScreen.js +++ b/web/src/screens/endScreen.js @@ -102,7 +102,7 @@ export function renderEndScreen(root, { stats, text, attribution, explanation, s <span></span><span>Racer</span><span>WPM</span><span>Acc</span><span>Time</span> </div> ` + list.map(r => ` - <div class="mp-standings-row${r.me ? ' me' : ''}${r.place ? '' : ' racing'}" style="--racer-color: ${r.color || 'var(--primary-color)'}"> + <div class="mp-standings-row${r.me ? ' me' : ''}${r.place ? '' : ' racing'}" data-racer-color="${r.color || 'var(--primary-color)'}"> <span class="mp-standings-place">${r.place ? r.place : '·'}</span> <span class="mp-standings-name">${escapeHtml(r.name)}${r.me ? ' (you)' : ''}</span> <span class="mp-standings-wpm">${Math.round(r.wpm)}</span> @@ -110,6 +110,11 @@ export function renderEndScreen(root, { stats, text, attribution, explanation, s <span class="mp-standings-time">${r.place && r.time != null ? `${r.time.toFixed(1)}s` : 'racing'}</span> </div> `).join(''); + // See multiplayerScreen.js: a style attribute is blocked by the CSP, + // the same property set through element.style is not. + standingsRows.querySelectorAll('[data-racer-color]').forEach(el => { + el.style.setProperty('--racer-color', el.dataset.racerColor); + }); } if (standings && standings.length) paintStandings(standings); const offStandings = onStandingsUpdate ? onStandingsUpdate(paintStandings) : null; diff --git a/web/src/screens/multiplayerScreen.js b/web/src/screens/multiplayerScreen.js index ba051c6..073d3cc 100644 --- a/web/src/screens/multiplayerScreen.js +++ b/web/src/screens/multiplayerScreen.js @@ -26,6 +26,18 @@ function racerColors(players, myId) { return colors; } +// A style="" attribute in markup is refused by the Content-Security-Policy; +// the same property set through element.style is not. Markup carries the +// value in a data attribute and this applies it. +function applyInlineStyles(container) { + container.querySelectorAll('[data-racer-color]').forEach(el => { + el.style.setProperty('--racer-color', el.dataset.racerColor); + }); + container.querySelectorAll('[data-percent]').forEach(el => { + el.style.width = `${el.dataset.percent}%`; + }); +} + // No account required - a quick race with friends shouldn't need signing // in, so this only prefills the name field when one exists rather than // gating the whole feature behind it (unlike Friends/Leaderboard, which @@ -166,11 +178,12 @@ export function renderMultiplayerScreen(root, { onBack, onFinish, onShowStats, o function paintPlayers() { const colors = racerColors(players, myId); listEl.innerHTML = players.map(p => ` - <div class="leaderboard-row" style="--racer-color: ${colors[p.id]}"> + <div class="leaderboard-row" data-racer-color="${colors[p.id]}"> <div class="leaderboard-name"><span class="mp-racer-dot"></span>${escapeHtml(p.name)}${p.id === myId ? ' (you)' : ''}</div> <div class="leaderboard-acc">${p.ready ? 'Ready' : 'Not ready'}</div> </div> `).join(''); + applyInlineStyles(listEl); } paintPlayers(); @@ -251,12 +264,13 @@ export function renderMultiplayerScreen(root, { onBack, onFinish, onShowStats, o const me = p.id === myId; const prog = progressById[p.id] || {}; return ` - <div class="mp-racer-row${me ? ' me' : ''}" style="--racer-color: ${colors[p.id]}"> + <div class="mp-racer-row${me ? ' me' : ''}" data-racer-color="${colors[p.id]}"> <span class="mp-racer-name">${escapeHtml(p.name)}${me ? ' (you)' : ''}</span> - <div class="mp-racer-bar"><div class="mp-racer-bar-fill" style="width:${prog.percent || 0}%"></div></div> + <div class="mp-racer-bar"><div class="mp-racer-bar-fill" data-percent="${prog.percent || 0}"></div></div> <span class="mp-racer-wpm">${Math.round(prog.wpm || 0)}</span> </div>`; }).join(''); + applyInlineStyles(opponents); } paintOpponents(); diff --git a/web/src/screens/storeScreen.js b/web/src/screens/storeScreen.js index 5a533d9..d1675a7 100644 --- a/web/src/screens/storeScreen.js +++ b/web/src/screens/storeScreen.js @@ -31,7 +31,7 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder const owned = !signedOut && mine.owned.includes(item.id); const equipped = !signedOut && (mine.equipped_caret === item.id || mine.equipped_flair === item.id); const swatch = item.category === 'caret' - ? `<span class="store-swatch" style="background:${escapeHtml(item.value)}"></span>` + ? `<span class="store-swatch" data-swatch-colour="${escapeHtml(item.value)}"></span>` : `<span class="store-swatch store-flair-swatch">${FLAIR_ICONS[item.value] || ''}</span>`; return ` <div class="leaderboard-row store-item-row"> @@ -72,6 +72,12 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder </div> `; root.querySelectorAll('[data-action="menu"]').forEach(el => el.addEventListener('click', onBack)); + // The swatch colour comes from the catalogue, so it cannot be a class. + // Set through element.style because a style attribute is blocked by the + // Content-Security-Policy. + root.querySelectorAll('[data-swatch-colour]').forEach(el => { + el.style.background = el.dataset.swatchColour; + }); attachTooltips(root); const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends }); |