srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/web/src/screens
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2025-12-14 09:24:00 +0200
committersrdusr <[email protected]>2025-12-14 09:24:00 +0200
commit3dbebfbc9345d2603908f32c0dabebc0ff21feb3 (patch)
tree61a4f60187046a087424845eb8e22239ccd7d6ee /web/src/screens
parent8fc4440150ce84c9cc3f5ec13d50beb58a5b65bc (diff)
downloadtyperpunk-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.js162
-rw-r--r--web/src/screens/mainMenu.js34
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">&mdash; ${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))}% &middot; ${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">&times;</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.';