diff options
| -rw-r--r-- | crates/server/migrations/0010_text_submissions.sql | 28 | ||||
| -rw-r--r-- | crates/server/src/main.rs | 2 | ||||
| -rw-r--r-- | crates/server/src/state.rs | 4 | ||||
| -rw-r--r-- | crates/server/src/texts.rs | 286 | ||||
| -rw-r--r-- | web/src/app.js | 53 | ||||
| -rw-r--r-- | web/src/customLibrary.js | 86 | ||||
| -rw-r--r-- | web/src/customText.js | 85 | ||||
| -rw-r--r-- | web/src/screens/contributeScreen.js | 162 | ||||
| -rw-r--r-- | web/src/screens/mainMenu.js | 34 | ||||
| -rw-r--r-- | web/src/siteFooter.js | 5 | ||||
| -rw-r--r-- | web/src/styles.css | 129 |
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">— ${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.'; 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); +} |