diff options
| author | srdusr <[email protected]> | 2025-12-14 09:24:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2025-12-14 09:24:00 +0200 |
| commit | 3dbebfbc9345d2603908f32c0dabebc0ff21feb3 (patch) | |
| tree | 61a4f60187046a087424845eb8e22239ccd7d6ee /web/src/screens | |
| parent | 8fc4440150ce84c9cc3f5ec13d50beb58a5b65bc (diff) | |
| download | typerpunk-3dbebfbc9345d2603908f32c0dabebc0ff21feb3.tar.gz typerpunk-3dbebfbc9345d2603908f32c0dabebc0ff21feb3.zip | |
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.
Diffstat (limited to 'web/src/screens')
| -rw-r--r-- | web/src/screens/contributeScreen.js | 162 | ||||
| -rw-r--r-- | web/src/screens/mainMenu.js | 34 |
2 files changed, 192 insertions, 4 deletions
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 ` + <div class="submission-row" data-id="${escapeHtml(s.id)}"> + <div class="submission-head"> + <span class="submission-category">${escapeHtml(s.category)}</span> + ${moderating && s.submitted_by ? `<span class="submission-by">by ${escapeHtml(s.submitted_by)}</span>` : ''} + <span class="submission-status status-${escapeHtml(s.status)}">${statusLabel(s.status)}</span> + </div> + <div class="submission-content">${escapeHtml(s.content)}</div> + ${s.attribution ? `<div class="submission-attribution">— ${escapeHtml(s.attribution)}</div>` : ''} + ${s.reject_reason ? `<div class="submission-reason">${escapeHtml(s.reject_reason)}</div>` : ''} + ${moderating ? ` + <div class="submission-actions"> + <button class="menu-button small primary" data-action="approve">Approve</button> + <button class="menu-button small quiet" data-action="reject">Reject</button> + </div>` : ''} + </div>`; + } + + function render() { + const user = getUser(); + root.innerHTML = ` + <div class="stats-screen"> + <button class="screen-close" data-action="menu" aria-label="Close" data-tooltip="Close (Esc)">${CLOSE_ICON}</button> + <div class="logo" data-action="menu">TyperPunk</div> + <h2>Contribute a passage</h2> + ${!user ? ` + <div class="stats-empty">Sign in to submit a passage. Everything submitted is reviewed before anyone types it.</div> + <button class="menu-button" data-action="go-account">Sign In</button> + ` : ` + <div class="settings-hint">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.</div> + <form class="account-form contribute-form"> + <textarea class="custom-textarea contribute-content" rows="3" placeholder="The passage itself" required></textarea> + <div class="contribute-count"><span class="contribute-len">0</span> / ${MAX_CONTENT}</div> + <input class="account-input contribute-attribution" type="text" placeholder="Where it is from (author, film, book)" maxlength="120"> + <select class="account-input contribute-category"> + ${CATEGORIES.map(c => `<option value="${c}">${c}</option>`).join('')} + </select> + <button class="menu-button primary" type="submit">Submit for review</button> + <div class="custom-error contribute-error"></div> + </form> + + ${mine.length ? ` + <h3>Your submissions</h3> + <div class="submission-list">${mine.map(s => submissionRow(s, false)).join('')}</div> + ` : ''} + + ${isModerator ? ` + <h3>Review queue${queue.length ? ` (${queue.length})` : ''}</h3> + ${queue.length + ? `<div class="submission-list">${queue.map(s => submissionRow(s, true)).join('')}</div>` + : `<div class="stats-empty">Nothing waiting.</div>`} + ` : ''} + `} + </div> + `; + + 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) { </label> <span class="custom-file-name"></span> </div> + ${documents && documents.length ? ` + <div class="custom-library"> + <div class="settings-hint">Your documents</div> + ${documents.map(d => ` + <div class="custom-doc" data-doc="${escapeHtml(d.id)}"> + <button class="custom-doc-open" data-action="open-doc" data-doc="${escapeHtml(d.id)}"> + <span class="custom-doc-name">${escapeHtml(d.name)}</span> + <span class="custom-doc-progress">${Math.min(100, Math.round((d.position / Math.max(1, d.chunkCount)) * 100))}% · ${d.chunkCount} segments</span> + </button> + <button class="custom-doc-remove" data-action="remove-doc" data-doc="${escapeHtml(d.id)}" aria-label="Remove" data-tooltip="Remove from your documents">×</button> + </div> + `).join('')} + </div>` : ''} <div class="custom-panel-row"> <button class="menu-button small" data-action="use-pasted">Use this text</button> <button class="menu-button small ghost" data-action="close-custom">Cancel</button> @@ -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.'; |