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/app.js | |
| 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/app.js')
| -rw-r--r-- | web/src/app.js | 53 |
1 files changed, 52 insertions, 1 deletions
diff --git a/web/src/app.js b/web/src/app.js index 55686be..123ad6f 100644 --- a/web/src/app.js +++ b/web/src/app.js @@ -11,7 +11,10 @@ import { renderMultiplayerScreen } from './screens/multiplayerScreen.js'; import { renderLyricsScreen } from './screens/lyricsScreen.js'; import { renderPublicProfileScreen } from './screens/publicProfileScreen.js'; import { renderStoreScreen } from './screens/storeScreen.js'; +import { renderContributeScreen } from './screens/contributeScreen.js'; import { createGame, freeGame } from './game.js'; +import { api } from './api.js'; +import { saveDocument, setPosition, getDocument, listDocuments, removeDocument } from './customLibrary.js'; import { getSettings } from './settings.js'; import { generateWordStream, generateWeakKeyStream, wordCountForDuration } from './wordGenerator.js'; import { getWeakChars } from './keyStats.js'; @@ -42,6 +45,22 @@ export function startApp(root, localTexts) { let game = null; let cleanupScreen = null; + // Approved community submissions, merged on top of the bundled dataset. + // Best-effort: the app is fully usable on the packs it ships with, so a + // server that is down or unreachable must not delay or break startup. + api.get('/api/texts') + .then(items => { + if (!Array.isArray(items) || items.length === 0) return; + allTexts = allTexts.concat(items.map(t => ({ + category: t.category, + content: t.content, + attribution: t.attribution || undefined, + language: t.language || undefined, + }))); + categories = uniqueCategories(allTexts); + }) + .catch(() => {}); + const onlineUrl = window.TYPERPUNK_TEXTS_URL; if (onlineUrl) { fetch(onlineUrl, { cache: 'no-store' }) @@ -114,8 +133,25 @@ export function startApp(root, localTexts) { startGame(); }, customText, + // Documents are kept so a set of notes survives a reload - see + // customLibrary.js. Purely local; nothing is uploaded. + onOpenDocument: id => { + const doc = getDocument(id); + if (!doc) return; + const parsed = parseCustomContent(doc.raw, doc.name); + customText = { name: doc.name, docId: doc.id, ...parsed }; + session = { type: 'custom', customIndex: Math.min(doc.position || 0, parsed.chunks.length - 1) }; + startGame(); + }, + documents: listDocuments(), + onRemoveDocument: id => { removeDocument(id); showMainMenu(); }, onLoadCustom: loaded => { - customText = loaded; + const doc = saveDocument({ + name: loaded.name, + raw: loaded.raw ?? '', + chunkCount: loaded.chunks.length, + }); + customText = { ...loaded, docId: doc.id }; showMainMenu(); }, onClearCustom: () => { @@ -136,6 +172,7 @@ export function startApp(root, localTexts) { onShowStore: showStore, onShowLyrics: showLyrics, onShowPrivacy: showPrivacy, + onShowContribute: showContribute, onSimulateTest: simulateTest, }); } @@ -175,6 +212,12 @@ export function startApp(root, localTexts) { // Written from what the code actually does, not from a template: this app // has no analytics, no third-party scripts and no npm runtime dependencies, // so there is genuinely very little to disclose. + function showContribute() { + teardown(); + setScreen('contribute'); + cleanupScreen = renderContributeScreen(root, { onBack: showMainMenu, onShowStats: showStats, onShowPlaceholder: showPlaceholder, onShowAccount: showAccount, onShowLeaderboard: showLeaderboard, onShowFriends: showFriends, onShowMultiplayer: showMultiplayer, onShowStore: showStore }); + } + function showPrivacy() { showPlaceholder('Privacy', [ 'On this device, in your browser: your theme, typing settings, personal bests, lifetime stats and per-key accuracy. Clearing site data removes all of it. Nothing here is sent anywhere unless you sign in.', @@ -339,6 +382,7 @@ export function startApp(root, localTexts) { if (session.type === 'custom' && customText) { if (session.customIndex + 1 < customText.chunks.length) { session.customIndex += 1; + if (customText.docId) setPosition(customText.docId, session.customIndex); } else { showMainMenu(); return; @@ -348,6 +392,13 @@ export function startApp(root, localTexts) { } function showEndScreen(result) { + // Record the segment as done as soon as it is finished, not when the + // next one is started. Closing the tab after finishing a segment + // otherwise lost it, and returning to a set of notes at the segment + // you had already typed is exactly the thing this is meant to avoid. + if (session.type === 'custom' && customText?.docId) { + setPosition(customText.docId, Math.min(session.customIndex + 1, customText.chunks.length)); + } teardown(); setScreen('end'); cleanupScreen = renderEndScreen(root, { |