From 3dbebfbc9345d2603908f32c0dabebc0ff21feb3 Mon Sep 17 00:00:00 2001 From: srdusr <99972264+srdusr@users.noreply.github.com> Date: Sun, 14 Dec 2025 09:24:00 +0200 Subject: Add community text submissions, and make custom text usable for study Submissions - POST /api/texts proposes a passage; nothing reaches players until a moderator approves it. GET /api/texts serves the approved set, which the client merges on top of its bundled packs at startup. - Validation the server enforces rather than trusts: category from a fixed list, 40 to 600 characters, no control characters (a newline makes a passage untypeable in a single-line input), attribution length, and a unique index on md5(lower(btrim(content))) so the same passage cannot be submitted twice under different whitespace or casing. - Moderation is a flag on users. The queue and the review endpoint both refuse a non-moderator, and reviewing an already-reviewed submission is a 404 rather than a silent second write. - Submissions are rate limited per user: enough for a real contributor, not enough to fill the queue from a script. - A Contribute screen carries the form, your own submissions with their status, and - for moderators only - the review queue. This is the half of TypeRacer's model the packs could not reach by authoring: their corpus is roughly twelve thousand passages, grown by submission. Custom text as a study tool - Imported documents are kept between visits, with how far through each one you are. Custom text lived only in memory, so importing a set of notes and reloading the page lost them - fine for pasting a paragraph to race, useless for working through a file over several sittings. - Position is recorded when a segment is finished, not when the next is started, so closing the tab after a segment does not lose it. - Markdown is chunked as markdown: fenced code blocks are kept whole and typed line by line, and the decoration - hashes, asterisks, backticks, link brackets, table pipes - is stripped so what you retype is the material rather than the punctuation around it. - Everything stays on the device. Notes are not uploaded anywhere. Fixed while doing it: a chunk could contain a newline, which cannot be typed in a single-line input at all. Any paragraph with a line break inside it -- ordinary in notes and in wrapped prose - produced an unfinishable segment. Whitespace inside a chunk is now flattened. --- web/src/screens/contributeScreen.js | 162 ++++++++++++++++++++++++++++++++++++ web/src/screens/mainMenu.js | 34 +++++++- 2 files changed, 192 insertions(+), 4 deletions(-) create mode 100644 web/src/screens/contributeScreen.js (limited to 'web/src/screens') diff --git a/web/src/screens/contributeScreen.js b/web/src/screens/contributeScreen.js new file mode 100644 index 0000000..4464ae2 --- /dev/null +++ b/web/src/screens/contributeScreen.js @@ -0,0 +1,162 @@ +import { escapeHtml } from '../util.js'; +import { renderCornerRail } from '../cornerRail.js'; +import { attachTooltips } from '../tooltip.js'; +import { renderTopRail } from '../topRail.js'; +import { api, ApiError } from '../api.js'; +import { getUser } from '../auth.js'; +import { CLOSE_ICON } from './icons.js'; + +// Submitting a passage, and - for moderators - reviewing what others have +// submitted. A bundled dataset stops growing at whatever fits in the +// repository; this is how a corpus gets past that. +const CATEGORIES = [ + 'quotes', 'literature', 'movies', 'anime', 'science', 'history', + 'technology', 'nature', 'philosophy', 'business', 'general', + 'programming', 'shell', 'sysadmin', 'hacking', +]; + +// Mirrors the server's own limits, so the count turns red before a submission +// is rejected rather than after. +const MIN_CONTENT = 40; +const MAX_CONTENT = 600; + +export function renderContributeScreen(root, { onBack, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore }) { + let mine = []; + let queue = []; + let isModerator = false; + let cleanupInner = null; + + function statusLabel(s) { + if (s === 'approved') return 'Approved'; + if (s === 'rejected') return 'Not accepted'; + return 'Awaiting review'; + } + + async function load() { + if (!getUser()) return; + try { mine = await api.get('/api/texts/mine'); } catch { mine = []; } + // A 401 here simply means "not a moderator", which is the ordinary + // case rather than an error worth showing. + try { queue = await api.get('/api/texts/queue'); isModerator = true; } + catch { queue = []; isModerator = false; } + } + + function submissionRow(s, moderating) { + return ` +
+
+ ${escapeHtml(s.category)} + ${moderating && s.submitted_by ? `by ${escapeHtml(s.submitted_by)}` : ''} + ${statusLabel(s.status)} +
+
${escapeHtml(s.content)}
+ ${s.attribution ? `
— ${escapeHtml(s.attribution)}
` : ''} + ${s.reject_reason ? `
${escapeHtml(s.reject_reason)}
` : ''} + ${moderating ? ` +
+ + +
` : ''} +
`; + } + + function render() { + const user = getUser(); + root.innerHTML = ` +
+ + +

Contribute a passage

+ ${!user ? ` +
Sign in to submit a passage. Everything submitted is reviewed before anyone types it.
+ + ` : ` +
Something worth typing: a line from a book, a film, a song, or a command worth knowing. It is reviewed before it reaches anyone else.
+
+ +
0 / ${MAX_CONTENT}
+ + + +
+
+ + ${mine.length ? ` +

Your submissions

+
${mine.map(s => submissionRow(s, false)).join('')}
+ ` : ''} + + ${isModerator ? ` +

Review queue${queue.length ? ` (${queue.length})` : ''}

+ ${queue.length + ? `
${queue.map(s => submissionRow(s, true)).join('')}
` + : `
Nothing waiting.
`} + ` : ''} + `} +
+ `; + + root.querySelectorAll('[data-action="menu"]').forEach(el => el.addEventListener('click', onBack)); + root.querySelector('[data-action="go-account"]')?.addEventListener('click', onShowAccount); + attachTooltips(root); + const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends }); + const cleanupRail = renderCornerRail(root, { onShowStats, onShowPlaceholder, onShowLeaderboard, onShowMultiplayer, onShowStore }); + cleanupInner = () => { cleanupTheme(); cleanupRail(); }; + + const form = root.querySelector('.contribute-form'); + if (form) { + const content = form.querySelector('.contribute-content'); + const lenEl = form.querySelector('.contribute-len'); + const errorEl = form.querySelector('.contribute-error'); + const paint = () => { + const n = content.value.trim().length; + lenEl.textContent = String(n); + lenEl.classList.toggle('bad', n > 0 && (n < MIN_CONTENT || n > MAX_CONTENT)); + }; + content.addEventListener('input', paint); + paint(); + + form.addEventListener('submit', async e => { + e.preventDefault(); + errorEl.textContent = ''; + try { + await api.post('/api/texts', { + category: form.querySelector('.contribute-category').value, + content: content.value, + attribution: form.querySelector('.contribute-attribution').value || null, + }); + await load(); + render(); + } catch (err) { + errorEl.textContent = err instanceof ApiError ? err.message : 'Could not submit - try again.'; + } + }); + } + + root.querySelectorAll('.submission-actions button').forEach(btn => { + btn.addEventListener('click', async () => { + const row = btn.closest('.submission-row'); + const decision = btn.dataset.action === 'approve' ? 'approve' : 'reject'; + const reason = decision === 'reject' + ? window.prompt('Why is this not accepted? (optional, shown to the submitter)') || null + : null; + try { + await api.post(`/api/texts/${row.dataset.id}/review`, { decision, reason }); + await load(); + render(); + } catch { + // Someone else reviewed it first; reloading shows the truth. + await load(); + render(); + } + }); + }); + } + + load().then(render); + render(); + + return () => { if (cleanupInner) cleanupInner(); }; +} diff --git a/web/src/screens/mainMenu.js b/web/src/screens/mainMenu.js index 9dba5df..1a3ce87 100644 --- a/web/src/screens/mainMenu.js +++ b/web/src/screens/mainMenu.js @@ -68,7 +68,7 @@ function wordListTierLabel(tier) { } export function renderMainMenu(root, props) { - const { onStartGame, onPickAndStart, onShowPrivacy, categories, selectedCategory, onSelectCategory, customText, onLoadCustom, onClearCustom, onStartCustom, onStartPassive, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowLyrics, onShowStore, onSimulateTest } = props; + const { onStartGame, onPickAndStart, onShowPrivacy, onShowContribute, documents, onOpenDocument, onRemoveDocument, categories, selectedCategory, onSelectCategory, customText, onLoadCustom, onClearCustom, onStartCustom, onStartPassive, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowLyrics, onShowStore, onSimulateTest } = props; const modes = ['random', 'words', 'time', 'zen', 'practice', ...categories, 'custom']; // The picker used to be one flat list of 18 entries mixing two unrelated // things: how a test is generated (Random/Words/Timed/Zen/Practice/Custom) @@ -182,6 +182,19 @@ export function renderMainMenu(root, props) { + ${documents && documents.length ? ` +
+
Your documents
+ ${documents.map(d => ` +
+ + +
+ `).join('')} +
` : ''}
@@ -212,7 +225,7 @@ export function renderMainMenu(root, props) { attachTooltips(root); const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends }); - const cleanupFooter = renderSiteFooter(root, { onShowPrivacy }); + const cleanupFooter = renderSiteFooter(root, { onShowPrivacy, onShowContribute }); // Friends-online lives in the top rail beside the Friends control. @@ -306,6 +319,16 @@ export function renderMainMenu(root, props) { }; document.addEventListener('keydown', handleEnterToStart); + root.querySelectorAll('[data-action="open-doc"]').forEach(el => { + el.addEventListener('click', () => onOpenDocument(el.dataset.doc)); + }); + root.querySelectorAll('[data-action="remove-doc"]').forEach(el => { + el.addEventListener('click', e => { + e.stopPropagation(); + onRemoveDocument(el.dataset.doc); + }); + }); + root.querySelector('[data-action="stats"]').addEventListener('click', onShowStats); root.querySelector('[data-action="leaderboard"]').addEventListener('click', onShowLeaderboard); @@ -505,12 +528,15 @@ export function renderMainMenu(root, props) { errorBox.textContent = "That file doesn't look like text - try a .txt, .md, or code file."; return; } - const { chunks, language, timed } = parseCustomContent(raw, name); + const { chunks, language, timed, markdown } = parseCustomContent(raw, name); if (chunks.length === 0) { errorBox.textContent = 'Could not find any typeable text in that content.'; return; } - onLoadCustom({ name, chunks, language, timed }); + // `raw` travels with it so the document can be stored and + // re-chunked later - switching a markdown file between prose and + // verbatim needs the original, not the chunks. + onLoadCustom({ name, chunks, language, timed, markdown, raw }); } catch (err) { console.error('Failed to load custom text:', err); errorBox.textContent = 'Failed to read that file.'; -- cgit v1.2.3