srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--crates/server/migrations/0010_text_submissions.sql28
-rw-r--r--crates/server/src/main.rs2
-rw-r--r--crates/server/src/state.rs4
-rw-r--r--crates/server/src/texts.rs286
-rw-r--r--web/src/app.js53
-rw-r--r--web/src/customLibrary.js86
-rw-r--r--web/src/customText.js85
-rw-r--r--web/src/screens/contributeScreen.js162
-rw-r--r--web/src/screens/mainMenu.js34
-rw-r--r--web/src/siteFooter.js5
-rw-r--r--web/src/styles.css129
11 files changed, 866 insertions, 8 deletions
diff --git a/crates/server/migrations/0010_text_submissions.sql b/crates/server/migrations/0010_text_submissions.sql
new file mode 100644
index 0000000..b735a8f
--- /dev/null
+++ b/crates/server/migrations/0010_text_submissions.sql
@@ -0,0 +1,28 @@
+- Community-submitted passages, the way a text corpus actually grows past
+- what fits in a repository. Nothing here reaches players until a moderator
+- approves it.
+ALTER TABLE users ADD COLUMN is_moderator BOOLEAN NOT NULL DEFAULT FALSE;
+
+CREATE TABLE text_submissions (
+ id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ category TEXT NOT NULL,
+ content TEXT NOT NULL,
+ attribution TEXT,
+ - Syntax language for code passages; NULL for prose.
+ language TEXT,
+ status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected')),
+ reject_reason TEXT,
+ reviewed_by TEXT REFERENCES users(id) ON DELETE SET NULL,
+ reviewed_at TEXT,
+ created_at TEXT NOT NULL
+);
+
+CREATE INDEX idx_text_submissions_status ON text_submissions(status);
+CREATE INDEX idx_text_submissions_user ON text_submissions(user_id);
+
+- One submission per passage, regardless of whitespace or case. Built on
+- md5() rather than a stored hash column so the constraint cannot drift out
+- of step with the content it is derived from.
+CREATE UNIQUE INDEX idx_text_submissions_dedupe
+ ON text_submissions (md5(lower(btrim(content))));
diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs
index 0ce169d..3838316 100644
--- a/crates/server/src/main.rs
+++ b/crates/server/src/main.rs
@@ -10,6 +10,7 @@ mod rate_limit;
mod spotify;
mod state;
mod stats;
+mod texts;
use crate::state::RaceText;
use axum::http::{HeaderValue, Method};
@@ -89,6 +90,7 @@ fn build_app(app_state: Arc<AppState>) -> Router {
.merge(multiplayer::router())
.merge(spotify::router())
.merge(lyrics::router())
+ .merge(texts::router())
.with_state(app_state)
}
diff --git a/crates/server/src/state.rs b/crates/server/src/state.rs
index fac6f29..ff583ac 100644
--- a/crates/server/src/state.rs
+++ b/crates/server/src/state.rs
@@ -39,6 +39,9 @@ pub struct AppState {
/// an open proxy unless it is bounded. Keyed by IP, since the endpoint is
/// reachable without an account.
pub lyrics_rate_limiter: RateLimiter,
+ /// Keyed by user: enough for someone contributing in a sitting, not
+ /// enough for a script to fill the moderation queue.
+ pub submission_rate_limiter: RateLimiter<String>,
/// Set from COOKIE_SECURE. Off for plain-HTTP local dev, must be on
/// behind TLS in production or browsers silently drop the cookie.
pub cookie_secure: bool,
@@ -68,6 +71,7 @@ impl AppState {
// rapid Words-10 sessions while still capping scripted spam.
stats_rate_limiter: RateLimiter::new(60, Duration::from_secs(5 * 60)),
lyrics_rate_limiter: RateLimiter::new(30, Duration::from_secs(60)),
+ submission_rate_limiter: RateLimiter::new(20, Duration::from_secs(60 * 60)),
cookie_secure,
rooms: new_registry(),
race_texts,
diff --git a/crates/server/src/texts.rs b/crates/server/src/texts.rs
new file mode 100644
index 0000000..46abe50
--- /dev/null
+++ b/crates/server/src/texts.rs
@@ -0,0 +1,286 @@
+//! Community text submissions.
+//!
+//! A bundled dataset can only get so large before it stops belonging in a
+//! repository. TypeRacer's corpus is roughly twelve thousand passages, and it
+//! got there through submissions rather than authoring. This is that path:
+//! anyone signed in can propose a passage, and nothing reaches players until
+//! a moderator approves it.
+
+use crate::auth::current_user_or_token;
+use crate::error::AppError;
+use crate::state::AppState;
+use axum::extract::{Path, Query, State};
+use axum::http::HeaderMap;
+use axum::response::IntoResponse;
+use axum::routing::{get, post};
+use axum::{Json, Router};
+use axum_extra::extract::CookieJar;
+use serde::{Deserialize, Serialize};
+use sqlx::Row;
+use std::sync::Arc;
+use time::OffsetDateTime;
+
+/// Long enough to be worth typing, short enough to stay a single race.
+const MIN_CONTENT: usize = 40;
+const MAX_CONTENT: usize = 600;
+const MAX_ATTRIBUTION: usize = 120;
+
+/// Where a submission may be filed. Kept in step with the bundled packs so a
+/// submission cannot invent a category the mode picker will never show.
+const CATEGORIES: &[&str] = &[
+ "anime", "business", "general", "hacking", "history", "literature",
+ "movies", "nature", "philosophy", "programming", "quotes", "science",
+ "shell", "sysadmin", "technology",
+];
+
+/// Syntax languages the client can highlight. Anything else is prose.
+const LANGUAGES: &[&str] = &["javascript", "python", "rust", "clike", "shell"];
+
+pub fn router() -> Router<Arc<AppState>> {
+ Router::new()
+ .route("/api/texts", post(submit).get(approved))
+ .route("/api/texts/mine", get(mine))
+ .route("/api/texts/queue", get(queue))
+ .route("/api/texts/:id/review", post(review))
+}
+
+#[derive(Debug, Deserialize)]
+pub struct SubmitBody {
+ pub category: String,
+ pub content: String,
+ pub attribution: Option<String>,
+ pub language: Option<String>,
+}
+
+#[derive(Debug, Serialize)]
+pub struct SubmissionView {
+ pub id: String,
+ pub category: String,
+ pub content: String,
+ pub attribution: Option<String>,
+ pub language: Option<String>,
+ pub status: String,
+ pub reject_reason: Option<String>,
+ pub created_at: String,
+ /// Only populated on the moderation queue.
+ pub submitted_by: Option<String>,
+}
+
+fn row_to_view(row: &sqlx::postgres::PgRow) -> SubmissionView {
+ SubmissionView {
+ id: row.try_get("id").unwrap_or_default(),
+ category: row.try_get("category").unwrap_or_default(),
+ content: row.try_get("content").unwrap_or_default(),
+ attribution: row.try_get("attribution").unwrap_or(None),
+ language: row.try_get("language").unwrap_or(None),
+ status: row.try_get("status").unwrap_or_default(),
+ reject_reason: row.try_get("reject_reason").unwrap_or(None),
+ created_at: row.try_get("created_at").unwrap_or_default(),
+ submitted_by: row.try_get("submitted_by").unwrap_or(None),
+ }
+}
+
+/// Rejects the things a passage must never contain, whatever else it says.
+/// Control characters break the per-character rendering, and a newline makes
+/// the passage untypeable in a single-line input.
+fn clean(content: &str) -> Result<String, AppError> {
+ let trimmed = content.trim();
+ if trimmed.chars().any(|c| c.is_control()) {
+ return Err(AppError::InvalidInput(
+ "a passage must be a single line with no control characters".into(),
+ ));
+ }
+ let count = trimmed.chars().count();
+ if count < MIN_CONTENT || count > MAX_CONTENT {
+ return Err(AppError::InvalidInput(format!(
+ "a passage must be between {MIN_CONTENT} and {MAX_CONTENT} characters"
+ )));
+ }
+ Ok(trimmed.to_string())
+}
+
+async fn submit(
+ State(state): State<Arc<AppState>>,
+ jar: CookieJar,
+ headers: HeaderMap,
+ Json(body): Json<SubmitBody>,
+) -> Result<impl IntoResponse, AppError> {
+ let user = current_user_or_token(&state.db, &jar, &headers)
+ .await
+ .ok_or(AppError::Unauthorized)?;
+
+ // Shares the stats limiter's shape: keyed by user, generous enough for a
+ // genuine contributor and mean enough to stop a script filling the queue.
+ if !state.submission_rate_limiter.check(user.id.clone()) {
+ return Err(AppError::RateLimited);
+ }
+
+ if !CATEGORIES.contains(&body.category.as_str()) {
+ return Err(AppError::InvalidInput("unknown category".into()));
+ }
+ let content = clean(&body.content)?;
+ let attribution = body.attribution.map(|a| a.trim().to_string()).filter(|a| !a.is_empty());
+ if attribution.as_ref().is_some_and(|a| a.chars().count() > MAX_ATTRIBUTION) {
+ return Err(AppError::InvalidInput("attribution is too long".into()));
+ }
+ let language = body.language.filter(|l| LANGUAGES.contains(&l.as_str()));
+
+ let result = sqlx::query(
+ "INSERT INTO text_submissions (id, user_id, category, content, attribution, language, status, created_at)
+ VALUES ($1, $2, $3, $4, $5, $6, 'pending', $7)",
+ )
+ .bind(uuid::Uuid::new_v4().to_string())
+ .bind(&user.id)
+ .bind(&body.category)
+ .bind(&content)
+ .bind(&attribution)
+ .bind(&language)
+ .bind(crate::auth::format_timestamp(OffsetDateTime::now_utc()))
+ .execute(&state.db)
+ .await;
+
+ match result {
+ Ok(_) => Ok(Json(serde_json::json!({ "status": "pending" }))),
+ // The unique index on the normalised content is the dedupe: someone
+ // has already proposed this passage, which is not the submitter's
+ // mistake and should not read like an error.
+ Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(AppError::InvalidInput(
+ "that passage has already been submitted".into(),
+ )),
+ Err(e) => Err(AppError::Internal(e.into())),
+ }
+}
+
+#[derive(Debug, Deserialize)]
+pub struct ApprovedQuery {
+ pub since: Option<String>,
+}
+
+/// Everything approved, for the client to merge into its bundled pool. Public
+/// and unauthenticated: these are the passages the app types.
+async fn approved(
+ State(state): State<Arc<AppState>>,
+ Query(q): Query<ApprovedQuery>,
+) -> Result<impl IntoResponse, AppError> {
+ let rows = sqlx::query(
+ "SELECT id, category, content, attribution, language, status, reject_reason, created_at,
+ NULL::text as submitted_by
+ FROM text_submissions
+ WHERE status = 'approved' AND ($1::text IS NULL OR created_at > $1)
+ ORDER BY created_at DESC
+ LIMIT 5000",
+ )
+ .bind(&q.since)
+ .fetch_all(&state.db)
+ .await?;
+
+ let items: Vec<SubmissionView> = rows.iter().map(row_to_view).collect();
+ Ok(Json(items))
+}
+
+async fn mine(
+ State(state): State<Arc<AppState>>,
+ jar: CookieJar,
+ headers: HeaderMap,
+) -> Result<impl IntoResponse, AppError> {
+ let user = current_user_or_token(&state.db, &jar, &headers)
+ .await
+ .ok_or(AppError::Unauthorized)?;
+ let rows = sqlx::query(
+ "SELECT id, category, content, attribution, language, status, reject_reason, created_at,
+ NULL::text as submitted_by
+ FROM text_submissions WHERE user_id = $1 ORDER BY created_at DESC LIMIT 200",
+ )
+ .bind(&user.id)
+ .fetch_all(&state.db)
+ .await?;
+ let items: Vec<SubmissionView> = rows.iter().map(row_to_view).collect();
+ Ok(Json(items))
+}
+
+async fn require_moderator(
+ state: &AppState,
+ jar: &CookieJar,
+ headers: &HeaderMap,
+) -> Result<(), AppError> {
+ let user = current_user_or_token(&state.db, jar, headers)
+ .await
+ .ok_or(AppError::Unauthorized)?;
+ let is_moderator: bool = sqlx::query_scalar("SELECT is_moderator FROM users WHERE id = $1")
+ .bind(&user.id)
+ .fetch_optional(&state.db)
+ .await?
+ .unwrap_or(false);
+ if !is_moderator {
+ // Unauthorized rather than NotFound: the caller is signed in, they
+ // simply are not a moderator, and saying so is not a leak.
+ return Err(AppError::Unauthorized);
+ }
+ Ok(())
+}
+
+async fn queue(
+ State(state): State<Arc<AppState>>,
+ jar: CookieJar,
+ headers: HeaderMap,
+) -> Result<impl IntoResponse, AppError> {
+ require_moderator(&state, &jar, &headers).await?;
+ let rows = sqlx::query(
+ "SELECT s.id, s.category, s.content, s.attribution, s.language, s.status,
+ s.reject_reason, s.created_at, u.username as submitted_by
+ FROM text_submissions s JOIN users u ON u.id = s.user_id
+ WHERE s.status = 'pending'
+ ORDER BY s.created_at ASC
+ LIMIT 200",
+ )
+ .fetch_all(&state.db)
+ .await?;
+ let items: Vec<SubmissionView> = rows.iter().map(row_to_view).collect();
+ Ok(Json(items))
+}
+
+#[derive(Debug, Deserialize)]
+pub struct ReviewBody {
+ /// "approve" or "reject".
+ pub decision: String,
+ pub reason: Option<String>,
+}
+
+async fn review(
+ State(state): State<Arc<AppState>>,
+ jar: CookieJar,
+ headers: HeaderMap,
+ Path(id): Path<String>,
+ Json(body): Json<ReviewBody>,
+) -> Result<impl IntoResponse, AppError> {
+ require_moderator(&state, &jar, &headers).await?;
+ let reviewer = current_user_or_token(&state.db, &jar, &headers)
+ .await
+ .ok_or(AppError::Unauthorized)?;
+
+ let status = match body.decision.as_str() {
+ "approve" => "approved",
+ "reject" => "rejected",
+ _ => return Err(AppError::InvalidInput("decision must be approve or reject".into())),
+ };
+
+ let affected = sqlx::query(
+ "UPDATE text_submissions
+ SET status = $1, reject_reason = $2, reviewed_by = $3, reviewed_at = $4
+ WHERE id = $5 AND status = 'pending'",
+ )
+ .bind(status)
+ .bind(body.reason.as_deref().filter(|r| !r.trim().is_empty()))
+ .bind(&reviewer.id)
+ .bind(crate::auth::format_timestamp(OffsetDateTime::now_utc()))
+ .bind(&id)
+ .execute(&state.db)
+ .await?
+ .rows_affected();
+
+ if affected == 0 {
+ // Either it does not exist or someone else already reviewed it.
+ return Err(AppError::NotFound);
+ }
+ Ok(Json(serde_json::json!({ "status": status })))
+}
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, {
diff --git a/web/src/customLibrary.js b/web/src/customLibrary.js
new file mode 100644
index 0000000..e97ae7c
--- /dev/null
+++ b/web/src/customLibrary.js
@@ -0,0 +1,86 @@
+const STORAGE_KEY = 'typerpunk:library';
+// A document is stored whole so it can be re-chunked later - switching
+// markdown between stripped and verbatim needs the original, not the chunks.
+// Ten is enough for the notes someone is actually working through, and keeps
+// localStorage well clear of its quota.
+const MAX_DOCUMENTS = 10;
+const MAX_RAW_CHARS = 400_000;
+
+// Imported documents, kept between visits.
+//
+// Custom text used to live only in memory: import a set of notes, reload the
+// page, and it was gone. That is fine for pasting a paragraph to race, and
+// useless for working through a file over several sittings - which is what
+// people actually do with their own notes.
+//
+// Everything here stays on the device. Notes are not uploaded anywhere.
+
+function load() {
+ try {
+ const raw = JSON.parse(localStorage.getItem(STORAGE_KEY));
+ return Array.isArray(raw) ? raw : [];
+ } catch {
+ return [];
+ }
+}
+
+function save(docs) {
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(docs));
+ return true;
+ } catch {
+ // Quota, or storage disabled. The document still works for this
+ // session; it simply will not be there next time.
+ return false;
+ }
+}
+
+export function listDocuments() {
+ return load().sort((a, b) => (b.lastOpened || 0) - (a.lastOpened || 0));
+}
+
+export function getDocument(id) {
+ return load().find(d => d.id === id) || null;
+}
+
+/// Stores a document, replacing any earlier import of the same name so
+/// re-importing an edited file updates it in place rather than accumulating
+/// copies. Returns the stored record.
+export function saveDocument({ name, raw, chunkCount }) {
+ const docs = load().filter(d => d.name !== name);
+ const existing = load().find(d => d.name === name);
+ const doc = {
+ id: existing?.id || `doc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
+ name,
+ raw: raw.slice(0, MAX_RAW_CHARS),
+ chunkCount,
+ // Re-importing an edited file keeps your place only if it still makes
+ // sense: past the end of the new version, start again.
+ position: Math.min(existing?.position || 0, Math.max(0, chunkCount - 1)),
+ addedAt: existing?.addedAt || Date.now(),
+ lastOpened: Date.now(),
+ };
+ docs.unshift(doc);
+ save(docs.slice(0, MAX_DOCUMENTS));
+ return doc;
+}
+
+/// Records how far through a document you have typed.
+export function setPosition(id, position) {
+ const docs = load();
+ const doc = docs.find(d => d.id === id);
+ if (!doc) return;
+ doc.position = Math.max(0, position);
+ doc.lastOpened = Date.now();
+ save(docs);
+}
+
+export function removeDocument(id) {
+ save(load().filter(d => d.id !== id));
+}
+
+/// How far through a document you are, as a percentage.
+export function progressOf(doc) {
+ if (!doc || !doc.chunkCount) return 0;
+ return Math.min(100, Math.round((doc.position / doc.chunkCount) * 100));
+}
diff --git a/web/src/customText.js b/web/src/customText.js
index dc74541..ca8c98b 100644
--- a/web/src/customText.js
+++ b/web/src/customText.js
@@ -65,6 +65,12 @@ function splitLong(str, maxLen = 400) {
return parts;
}
+/// Collapses the whitespace inside a chunk. The typing input is a single
+/// line, so a chunk containing a newline can never be finished.
+function flattenWhitespace(text) {
+ return text.replace(/\s+/g, ' ').trim();
+}
+
export function chunkPlainText(raw) {
const normalized = raw.replace(/\r\n/g, '\n').trim();
if (!normalized) return [];
@@ -73,7 +79,7 @@ export function chunkPlainText(raw) {
const chunks = [];
for (const block of source) {
for (const piece of splitLong(block)) {
- const trimmed = piece.trim();
+ const trimmed = flattenWhitespace(piece);
if (trimmed) chunks.push({ content: trimmed, time: null });
}
}
@@ -145,11 +151,86 @@ function parseLrc(raw) {
return chunks;
}
-export function parseCustomContent(raw, filename) {
+
+// Markdown notes are the most common thing someone brings to a typing app to
+// study: lecture notes, a cheatsheet, a page of documentation. Typed
+// verbatim, most of what you retype is punctuation - hashes, asterisks,
+// backticks and link brackets - rather than the material itself.
+//
+// `strip` removes the decoration and keeps the prose, which is the mode for
+// studying what the notes say. Left off, the file is typed exactly as
+// written, which is the mode for learning the syntax.
+export function chunkMarkdown(raw, { strip = true } = {}) {
+ const normalized = raw.replace(/\r\n/g, '\n').trim();
+ if (!normalized) return [];
+
+ // Fenced code blocks are extracted whole and never stripped: their
+ // punctuation is the point, and paragraph splitting would cut them apart.
+ const segments = [];
+ const fence = /```[^\n]*\n([\s\S]*?)```/g;
+ let last = 0;
+ let m;
+ while ((m = fence.exec(normalized)) !== null) {
+ if (m.index > last) segments.push({ text: normalized.slice(last, m.index), code: false });
+ segments.push({ text: m[1], code: true });
+ last = m.index + m[0].length;
+ }
+ if (last < normalized.length) segments.push({ text: normalized.slice(last), code: false });
+
+ const chunks = [];
+ for (const seg of segments) {
+ if (seg.code) {
+ // One line at a time: a code block is typed the way it is written.
+ for (const line of seg.text.split('\n')) {
+ const t = line.trim();
+ if (t) chunks.push({ content: t, time: null, code: true });
+ }
+ continue;
+ }
+ for (const block of seg.text.split(/\n\s*\n/)) {
+ let text = block.trim();
+ if (!text) continue;
+ if (strip) {
+ text = text
+ .replace(/^\s{0,3}#{1,6}\s+/gm, '') // heading markers
+ .replace(/^\s{0,3}>\s?/gm, '') // block quotes
+ .replace(/^\s*[-*+]\s+/gm, '') // bullet markers
+ .replace(/^\s*\d+[.)]\s+/gm, '') // ordered list markers
+ .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') // images -> alt text
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') // links -> label
+ .replace(/`([^`]+)`/g, '$1') // inline code
+ .replace(/(\*\*|__)(.*?)\1/g, '$2') // bold
+ .replace(/(\*|_)(.*?)\1/g, '$2') // italics
+ .replace(/^\s*([-*_]\s*){3,}$/gm, '') // horizontal rules
+ .replace(/\|/g, ' ') // table pipes
+ .replace(/[ \t]+/g, ' ')
+ .trim();
+ }
+ // A heading on its own becomes a one-word chunk that is not worth
+ // typing; fold it into nothing and let the paragraph follow.
+ if (!text || text.length < 3) continue;
+ for (const piece of splitLong(text)) {
+ const t = flattenWhitespace(piece);
+ if (t) chunks.push({ content: t, time: null });
+ }
+ }
+ }
+ return chunks;
+}
+
+export function parseCustomContent(raw, filename, options = {}) {
const ext = (filename || '').split('.').pop().toLowerCase();
if (ext === 'srt') return { chunks: parseSrt(raw), language: null, timed: true };
if (ext === 'vtt') return { chunks: parseVtt(raw), language: null, timed: true };
if (ext === 'lrc') return { chunks: parseLrc(raw), language: null, timed: true };
const language = languageForFilename(filename);
+ if (ext === 'md' || ext === 'markdown') {
+ return {
+ chunks: chunkMarkdown(raw, { strip: options.stripMarkdown !== false }),
+ language: null,
+ timed: false,
+ markdown: true,
+ };
+ }
return { chunks: chunkPlainText(raw), language, timed: false };
}
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.';
diff --git a/web/src/siteFooter.js b/web/src/siteFooter.js
index 1d5139e..eef193b 100644
--- a/web/src/siteFooter.js
+++ b/web/src/siteFooter.js
@@ -39,7 +39,7 @@ async function share(result) {
/// `result` is optional: with one, Share offers the score just achieved;
/// without, it offers the site.
-export function renderSiteFooter(root, { result, onShowPrivacy } = {}) {
+export function renderSiteFooter(root, { result, onShowPrivacy, onShowContribute } = {}) {
const wrap = document.createElement('div');
wrap.className = 'site-footer';
wrap.innerHTML = `
@@ -47,6 +47,8 @@ export function renderSiteFooter(root, { result, onShowPrivacy } = {}) {
<span class="site-footer-sep">·</span>
<a class="site-footer-link" href="${LINKS.github}" target="_blank" rel="noopener noreferrer" data-tooltip="Source on GitHub">GitHub</a>
<span class="site-footer-sep">·</span>
+ <button class="site-footer-link" data-action="contribute" data-tooltip="Submit a passage for other people to type">Contribute</button>
+ <span class="site-footer-sep">·</span>
<button class="site-footer-link" data-action="privacy" data-tooltip="What this site stores">Privacy</button>
<span class="site-footer-sep">·</span>
<span class="site-footer-version">v0.1.0</span>
@@ -61,6 +63,7 @@ export function renderSiteFooter(root, { result, onShowPrivacy } = {}) {
setTimeout(() => { shareBtn.textContent = 'Share'; }, 1800);
});
wrap.querySelector('[data-action="privacy"]').addEventListener('click', () => onShowPrivacy?.());
+ wrap.querySelector('[data-action="contribute"]').addEventListener('click', () => onShowContribute?.());
return () => wrap.remove();
}
diff --git a/web/src/styles.css b/web/src/styles.css
index 6a04db2..bb92393 100644
--- a/web/src/styles.css
+++ b/web/src/styles.css
@@ -2920,3 +2920,132 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar {
color: var(--primary-color);
border-color: var(--primary-color);
}
+
+/* Community submissions: the form, your own history, and the review queue. */
+.contribute-form { width: 100%; max-width: 34rem; }
+
+.contribute-count {
+ align-self: flex-end;
+ font-size: 0.7rem;
+ color: var(--sub-color);
+ font-family: 'JetBrains Mono', monospace;
+}
+
+.contribute-len.bad { color: var(--error-color); }
+
+.submission-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.6rem;
+ width: 100%;
+ max-width: 40rem;
+}
+
+.submission-row {
+ border: 1px solid var(--neutral-color);
+ border-radius: 8px;
+ padding: 0.75rem 0.9rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+ text-align: left;
+}
+
+.submission-head {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ font-size: 0.7rem;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ color: var(--sub-color);
+}
+
+.submission-status { margin-left: auto; }
+.submission-status.status-approved { color: var(--primary-color); }
+.submission-status.status-rejected { color: var(--error-color); }
+.submission-status.status-pending { color: var(--warning-color); }
+
+.submission-content {
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 0.85rem;
+ color: var(--text-color);
+ line-height: 1.5;
+}
+
+.submission-attribution,
+.submission-reason {
+ font-size: 0.75rem;
+ color: var(--sub-color);
+}
+
+.submission-reason { color: var(--error-color); }
+
+.submission-actions { display: flex; gap: 0.5rem; }
+
+/* Imported documents, kept between visits. Notes are worked through over
+ several sittings, so the list shows how far into each one you are. */
+.custom-library {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+ width: 100%;
+ margin-bottom: 0.5rem;
+}
+
+.custom-doc {
+ display: flex;
+ align-items: stretch;
+ gap: 0.35rem;
+}
+
+.custom-doc-open {
+ flex: 1 1 auto;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.15rem;
+ background: none;
+ border: 1px solid var(--neutral-color);
+ border-radius: 6px;
+ padding: 0.5rem 0.7rem;
+ color: var(--text-color);
+ font-family: inherit;
+ cursor: pointer;
+ text-align: left;
+ min-width: 0;
+}
+
+.custom-doc-open:hover {
+ border-color: var(--primary-color);
+}
+
+.custom-doc-name {
+ font-size: 0.85rem;
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.custom-doc-progress {
+ font-size: 0.7rem;
+ color: var(--sub-color);
+ font-family: 'JetBrains Mono', monospace;
+}
+
+.custom-doc-remove {
+ flex: 0 0 auto;
+ width: 2rem;
+ background: none;
+ border: 1px solid transparent;
+ border-radius: 6px;
+ color: var(--sub-color);
+ font-size: 1.1rem;
+ cursor: pointer;
+}
+
+.custom-doc-remove:hover {
+ color: var(--error-color);
+ border-color: color-mix(in srgb, var(--error-color) 45%, transparent);
+}