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 `
+