srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/web/src/screens
diff options
context:
space:
mode:
Diffstat (limited to 'web/src/screens')
-rw-r--r--web/src/screens/contributeScreen.js49
-rw-r--r--web/src/screens/endScreen.js7
-rw-r--r--web/src/screens/multiplayerScreen.js20
-rw-r--r--web/src/screens/storeScreen.js8
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 : '&middot;'}</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 });