diff --git a/api/src/handlers.rs b/api/src/handlers.rs index 36e8828..c1f8832 100644 --- a/api/src/handlers.rs +++ b/api/src/handlers.rs @@ -19,12 +19,6 @@ pub struct Question { answered_at: String, } -#[derive(Serialize)] -pub struct QuestionStats { - asked: i64, - answered: i64, -} - pub async fn get_questions( State(state): State>, ) -> Result>, StatusCode> { @@ -57,29 +51,6 @@ pub async fn get_questions( Ok(Json(questions)) } -pub async fn get_question_stats( - State(state): State>, -) -> Result, StatusCode> { - let db = state - .db - .lock() - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - let asked: i64 = db - .query_row("SELECT COUNT(*) FROM questions", [], |row| row.get(0)) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - let answered: i64 = db - .query_row( - "SELECT COUNT(*) FROM questions WHERE answer IS NOT NULL", - [], - |row| row.get(0), - ) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - Ok(Json(QuestionStats { asked, answered })) -} - #[derive(Deserialize)] pub struct SubmitQuestion { question: String, @@ -220,12 +191,10 @@ pub struct MtaHookResponse { } fn webhook_secret_matches(headers: &HeaderMap, expected_secret: &str) -> bool { - let expected_secret = expected_secret.trim(); let header_secret = headers .get("X-Webhook-Secret") .and_then(|v| v.to_str().ok()) - .unwrap_or("") - .trim(); + .unwrap_or(""); if header_secret == expected_secret { return true; } @@ -255,29 +224,7 @@ fn webhook_secret_matches(headers: &HeaderMap, expected_secret: &str) -> bool { None => return false, }; - password.trim() == expected_secret -} - -fn webhook_secret_debug(headers: &HeaderMap) -> String { - let header_secret = headers - .get("X-Webhook-Secret") - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - - let auth = headers - .get(axum::http::header::AUTHORIZATION) - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - - let decoded = auth - .strip_prefix("Basic ") - .and_then(|encoded| base64::engine::general_purpose::STANDARD.decode(encoded).ok()) - .and_then(|bytes| String::from_utf8(bytes).ok()) - .unwrap_or_default(); - - format!( - "x-webhook-secret={header_secret:?}; authorization={auth:?}; basic-decoded={decoded:?}" - ) + password == expected_secret } fn extract_qa_reply(payload: &MtaHookPayload, expected_domain: &str) -> Option<(i64, String)> { @@ -357,21 +304,6 @@ fn string_at_paths(value: &Value, paths: &[&[&str]]) -> Option { }) } -fn subject_from_headers_value(value: Option<&Value>) -> Option { - let headers = value?.as_array()?; - headers.iter().find_map(|header| { - let parts = header.as_array()?; - let name = parts.first()?.as_str()?.trim(); - if !name.eq_ignore_ascii_case("Subject") { - return None; - } - parts - .get(1)? - .as_str() - .map(|s| s.trim().to_string()) - }) -} - fn extract_qa_reply_from_value(payload: &Value, expected_domain: &str) -> Option<(i64, String)> { if let Some(messages) = payload.get("messages").and_then(Value::as_array) { for message in messages { @@ -386,7 +318,6 @@ fn extract_qa_reply_from_value(payload: &Value, expected_domain: &str) -> Option &["headers", "subject"], ], ) - .or_else(|| subject_from_headers_value(value_at_path(message, &["message", "headers"]))) .as_deref(), &string_at_paths(message, &[&["message", "contents"], &["contents"], &["raw_message"]]) .unwrap_or_default(), @@ -407,7 +338,6 @@ fn extract_qa_reply_from_value(payload: &Value, expected_domain: &str) -> Option &["headers", "subject"], ], ) - .or_else(|| subject_from_headers_value(value_at_path(payload, &["message", "headers"]))) .as_deref(), &string_at_paths(payload, &[&["message", "contents"], &["contents"], &["raw_message"]]) .unwrap_or_default(), @@ -450,11 +380,7 @@ pub async fn webhook( body: String, ) -> Result, (StatusCode, String)> { if !webhook_secret_matches(&headers, &state.webhook_secret) { - eprintln!( - "Rejected webhook: invalid secret; expected_len={}; {}", - state.webhook_secret.len(), - webhook_secret_debug(&headers) - ); + eprintln!("Rejected webhook: invalid secret"); return Err((StatusCode::UNAUTHORIZED, "invalid secret".to_string())); } @@ -589,28 +515,4 @@ mod tests { Some((9, "Answer body".to_string())) ); } - - #[test] - fn extracts_reply_from_value_with_header_pairs() { - let payload: Value = serde_json::from_str( - r#"{ - "envelope": { - "to": [{"address":"qa@extremist.software"}] - }, - "message": { - "headers": [ - ["From", " jet@extremist.software\r\n"], - ["Subject", " Re: 11 - hi\r\n"] - ], - "contents": "Answer from header pairs" - } - }"#, - ) - .unwrap(); - - assert_eq!( - extract_qa_reply_from_value(&payload, "extremist.software"), - Some((11, "Answer from header pairs".to_string())) - ); - } } diff --git a/api/src/serve.rs b/api/src/serve.rs index f381b5c..7d7afdf 100644 --- a/api/src/serve.rs +++ b/api/src/serve.rs @@ -52,7 +52,6 @@ pub async fn run() -> Result<(), Box> { "/api/questions", get(handlers::get_questions).post(handlers::post_question), ) - .route("/api/questions/stats", get(handlers::get_question_stats)) .route("/api/webhook", post(handlers::webhook)) .layer(CorsLayer::permissive()) .with_state(state); diff --git a/index.html b/index.html index 51ddb40..362f098 100644 --- a/index.html +++ b/index.html @@ -106,20 +106,30 @@ class="fixed top-0 left-0 -z-10 h-screen w-screen" aria-hidden="true" > -
-
+ +
diff --git a/public/pgp.txt b/public/pgp.txt deleted file mode 100644 index e69de29..0000000 diff --git a/public/ssh.txt b/public/ssh.txt deleted file mode 100644 index 8de92cb..0000000 --- a/public/ssh.txt +++ /dev/null @@ -1,2 +0,0 @@ -# Jet Pham SSH fingerprints -ssh-ed25519 SHA256:Ziw7a2bUA1ew4AFQLB8rk9G3l9I4/eRClf9OJMLMLUA diff --git a/src/components/frosted-box.ts b/src/components/frosted-box.ts index 3254ebc..86b8f9c 100644 --- a/src/components/frosted-box.ts +++ b/src/components/frosted-box.ts @@ -1,13 +1,13 @@ export function frostedBox(content: string, extraClasses?: string): string { return ` -
+
-
+
${content}
`; diff --git a/src/global.d.ts b/src/global.d.ts index a266ba5..9193a96 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -1,7 +1,5 @@ /// -declare const __COMMIT_SHA__: string; - declare module "*.txt?raw" { const content: string; export default content; diff --git a/src/lib/api.ts b/src/lib/api.ts index 9c51ba7..79b458a 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -6,118 +6,12 @@ export interface Question { answered_at: string; } -export interface QuestionStats { - asked: number; - answered: number; -} - -const DEV_QUESTIONS: Question[] = [ - { - id: 1, - question: "What is a fact about octopuses?", - answer: "An octopus has three hearts and blue blood.", - created_at: "2026-03-23T18:10:00.000Z", - answered_at: "2026-03-23T19:00:00.000Z", - }, - { - id: 2, - question: "What is a fact about axolotls?", - answer: - "An axolotl can regrow limbs, parts of its heart, and even parts of its brain.", - created_at: "2026-03-24T02:15:00.000Z", - answered_at: "2026-03-24T05:45:00.000Z", - }, - { - id: 3, - question: "What is a fact about crows?", - answer: "Crows can recognize human faces and remember them for years.", - created_at: "2026-03-25T08:30:00.000Z", - answered_at: "2026-03-25T09:05:00.000Z", - }, - { - id: 4, - question: "What is a fact about wombats?", - answer: "Wombats produce cube-shaped poop.", - created_at: "2026-03-25T11:10:00.000Z", - answered_at: "2026-03-25T11:40:00.000Z", - }, - { - id: 5, - question: "What is a fact about mantis shrimp?", - answer: - "A mantis shrimp can punch so fast it creates tiny cavitation bubbles in water.", - created_at: "2026-03-25T13:00:00.000Z", - answered_at: "2026-03-25T13:18:00.000Z", - }, - { - id: 6, - question: "What is a fact about sloths?", - answer: - "Some sloths can hold their breath longer than dolphins by slowing their heart rate.", - created_at: "2026-03-25T14:25:00.000Z", - answered_at: "2026-03-25T15:00:00.000Z", - }, - { - id: 7, - question: "What is a fact about owls?", - answer: - "An owl cannot rotate its eyes, so it turns its whole head instead.", - created_at: "2026-03-25T16:05:00.000Z", - answered_at: "2026-03-25T16:21:00.000Z", - }, - { - id: 8, - question: "What is a fact about capybaras?", - answer: - "Capybaras are the largest rodents in the world and are excellent swimmers.", - created_at: "2026-03-25T18:45:00.000Z", - answered_at: "2026-03-25T19:07:00.000Z", - }, - { - id: 9, - question: "What is a fact about penguins?", - answer: "Penguins have solid bones, which help them dive instead of float.", - created_at: "2026-03-25T21:20:00.000Z", - answered_at: "2026-03-25T21:55:00.000Z", - }, - { - id: 10, - question: "What is a fact about bats?", - answer: "Bats are the only mammals capable of sustained powered flight.", - created_at: "2026-03-26T00:10:00.000Z", - answered_at: "2026-03-26T00:32:00.000Z", - }, -]; - -const DEV_QUESTION_STATS: QuestionStats = { - asked: 16, - answered: DEV_QUESTIONS.length, -}; - export async function getQuestions(): Promise { - if (import.meta.env.DEV) return DEV_QUESTIONS; - const res = await fetch("/api/questions"); if (!res.ok) throw new Error("Failed to fetch questions"); return res.json() as Promise; } -export async function getQuestionStats(): Promise { - if (import.meta.env.DEV) return DEV_QUESTION_STATS; - - try { - const res = await fetch("/api/questions/stats"); - if (!res.ok) throw new Error("Failed to fetch question stats"); - return res.json() as Promise; - } catch { - const questions = await getQuestions(); - return { - asked: questions.length, - answered: questions.length, - }; - } -} - export async function submitQuestion(question: string): Promise { const res = await fetch("/api/questions", { method: "POST", @@ -125,8 +19,7 @@ export async function submitQuestion(question: string): Promise { body: JSON.stringify({ question }), }); if (!res.ok) { - if (res.status === 429) - throw new Error("Too many questions. Please try again later."); + if (res.status === 429) throw new Error("Too many questions. Please try again later."); throw new Error("Failed to submit question"); } } diff --git a/src/lib/qa.ts b/src/lib/qa.ts index 3fe2830..c4b6343 100644 --- a/src/lib/qa.ts +++ b/src/lib/qa.ts @@ -1,5 +1,4 @@ const DRAFT_KEY = "qa-draft"; -const LAST_PLACEHOLDER_KEY = "qa-last-placeholder"; const relativeTime = new Intl.RelativeTimeFormat(undefined, { numeric: "auto", }); @@ -33,19 +32,21 @@ export function clearQuestionDraft() { } } -export function formatDateOnly(dateString: string): string { +export function formatExactTimestamp(dateString: string): string { const date = getValidDate(dateString); if (!date) return dateString; - return date.toISOString().slice(0, 10); + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(date); } -export function formatFriendlyDate(dateString: string): string { +export function formatRelativeTimestamp(dateString: string): string { const date = getValidDate(dateString); if (!date) return dateString; - const now = Date.now(); - const diffMs = date.getTime() - now; + const diffMs = date.getTime() - Date.now(); const diffMinutes = Math.round(diffMs / 60000); const diffHours = Math.round(diffMs / 3600000); const diffDays = Math.round(diffMs / 86400000); @@ -53,36 +54,9 @@ export function formatFriendlyDate(dateString: string): string { if (Math.abs(diffMinutes) < 60) return relativeTime.format(diffMinutes, "minute"); if (Math.abs(diffHours) < 24) return relativeTime.format(diffHours, "hour"); - if (Math.abs(diffDays) < 14) return relativeTime.format(diffDays, "day"); + if (Math.abs(diffDays) < 30) return relativeTime.format(diffDays, "day"); - return formatDateOnly(dateString); -} - -export function pickPlaceholder(items: readonly T[]): T { - if (items.length === 1) return items[0]!; - - let lastIndex = -1; - try { - lastIndex = Number.parseInt( - sessionStorage.getItem(LAST_PLACEHOLDER_KEY) ?? "", - 10, - ); - } catch { - lastIndex = -1; - } - - let nextIndex = Math.floor(Math.random() * items.length); - if (nextIndex === lastIndex) { - nextIndex = - (nextIndex + 1 + Math.floor(Math.random() * (items.length - 1))) % - items.length; - } - - try { - sessionStorage.setItem(LAST_PLACEHOLDER_KEY, String(nextIndex)); - } catch { - // Ignore storage failures. - } - - return items[nextIndex]!; + return new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }).format( + date, + ); } diff --git a/src/lib/site.ts b/src/lib/site.ts deleted file mode 100644 index bc699f8..0000000 --- a/src/lib/site.ts +++ /dev/null @@ -1,47 +0,0 @@ -const CLEARNET_HOST = "jetpham.com"; -const ONION_HOST = - "jet7tetd43snvjx3ng5jrhuwm2yhyp76tjtct5mtofg64apokcgq7fqd.onion"; -const COMMIT_BASE_URL = "https://git.extremist.software/jet/website/commit/"; -const REPO_URL = "https://git.extremist.software/jet/website"; - -function isOnionHost(hostname: string): boolean { - return hostname.endsWith(".onion"); -} - -function getMirrorLink() { - if (isOnionHost(location.hostname)) { - return { - href: `https://${CLEARNET_HOST}`, - label: "clearnet", - }; - } - - return { - href: `http://${ONION_HOST}`, - label: ".onion", - }; -} - -export function renderFooter() { - const footer = document.getElementById("site-footer"); - if (!footer) return; - - const commitSha = __COMMIT_SHA__; - const shortSha = commitSha.slice(0, 7); - const mirror = getMirrorLink(); - - footer.innerHTML = ` -
- -
`; -} diff --git a/src/main.ts b/src/main.ts index d6d49dd..55c70e7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,14 +1,13 @@ import "~/styles/globals.css"; import init, { start } from "cgol"; import { route, initRouter } from "~/router"; -import { renderFooter } from "~/lib/site"; import { homePage } from "~/pages/home"; import { qaPage } from "~/pages/qa"; import { notFoundPage } from "~/pages/not-found"; -route("/", "Jet Pham - Home", homePage); -route("/qa", "Jet Pham - Q+A", qaPage); -route("*", "404 - Jet Pham", notFoundPage); +route("/", homePage); +route("/qa", qaPage); +route("*", notFoundPage); try { await init(); @@ -18,4 +17,3 @@ try { } initRouter(); -renderFooter(); diff --git a/src/pages/home.ts b/src/pages/home.ts index 66aa238..f6befdd 100644 --- a/src/pages/home.ts +++ b/src/pages/home.ts @@ -2,9 +2,8 @@ import Jet from "~/assets/Jet.txt?ansi"; import { frostedBox } from "~/components/frosted-box"; export function homePage(outlet: HTMLElement) { - outlet.classList.remove("qa-outlet"); outlet.innerHTML = ` -
+
${frostedBox(`
@@ -23,43 +22,20 @@ export function homePage(outlet: HTMLElement) { />
-
+
Contact - - + jet@extremist.software
-
+
Links
  1. Forgejo
  2. GitHub
  3. X
  4. Bluesky
  5. +
  6. .onion
`)}
`; - - const copyButton = document.getElementById("copy-email") as HTMLButtonElement; - const copyStatus = document.getElementById( - "copy-email-status", - ) as HTMLSpanElement; - let resetTimer: number | null = null; - - copyButton.addEventListener("click", async () => { - try { - await navigator.clipboard.writeText("jet@extremist.software"); - copyStatus.textContent = "copied"; - copyStatus.style.color = "var(--light-green)"; - } catch { - copyStatus.textContent = "copy failed"; - copyStatus.style.color = "var(--light-red)"; - } - - if (resetTimer !== null) window.clearTimeout(resetTimer); - resetTimer = window.setTimeout(() => { - copyStatus.textContent = ""; - resetTimer = null; - }, 1400); - }); } diff --git a/src/pages/not-found.ts b/src/pages/not-found.ts index 40fa4f0..0534b6f 100644 --- a/src/pages/not-found.ts +++ b/src/pages/not-found.ts @@ -1,9 +1,8 @@ import { frostedBox } from "~/components/frosted-box"; export function notFoundPage(outlet: HTMLElement) { - outlet.classList.remove("qa-outlet"); outlet.innerHTML = ` -
+
${frostedBox(`

404

Page not found.

diff --git a/src/pages/qa.ts b/src/pages/qa.ts index 67a4640..25ee47d 100644 --- a/src/pages/qa.ts +++ b/src/pages/qa.ts @@ -1,110 +1,82 @@ -import { - getQuestions, - getQuestionStats, - submitQuestion, - type Question, - type QuestionStats, -} from "~/lib/api"; +import { getQuestions, submitQuestion, type Question } from "~/lib/api"; import { clearQuestionDraft, - formatDateOnly, - pickPlaceholder, + formatExactTimestamp, + formatRelativeTimestamp, readQuestionDraft, writeQuestionDraft, } from "~/lib/qa"; import { frostedBox } from "~/components/frosted-box"; -const PLACEHOLDER_QUESTIONS = [ - "Why call yourself a software extremist?", - "What are you building at Noisebridge?", - "Why Forgejo over GitHub?", - "What is the weirdest thing in your nix-config?", - "Why did you write HolyC?", - "What do you like about San Francisco hacker culture?", - "What is your favorite project you've seen at TIAT?", - "What is your favorite project you've seen at Noisebridge?", - "What is your favorite hacker conference?", - "What is your cat's name?", - "What are your favorite programming languages and tools?", - "Who are your biggest inspirations?", -] as const; - function escapeHtml(str: string): string { const div = document.createElement("div"); div.textContent = str; return div.innerHTML; } -function formatQuestionTooltip(question: Question): string { - const askedExact = formatDateOnly(question.created_at); - const answeredExact = formatDateOnly(question.answered_at); +function formatQuestionDates(question: Question): string { + const asked = formatRelativeTimestamp(question.created_at); + const answered = formatRelativeTimestamp(question.answered_at); + const askedExact = formatExactTimestamp(question.created_at); + const answeredExact = formatExactTimestamp(question.answered_at); return ` -

Asked ${escapeHtml(askedExact)}

-

Answered ${escapeHtml(answeredExact)}

`; -} - -function formatRatio(stats: QuestionStats): string { - if (stats.asked === 0) return "0%"; - return `${Math.round((stats.answered / stats.asked) * 100)}%`; + Asked ${escapeHtml(asked)} + · + Answered ${escapeHtml(answered)}`; } function renderQuestions(list: HTMLElement, questions: Question[]) { if (questions.length === 0) { list.innerHTML = ` -
-

No answers yet

-

...

-
`; +
+ No answers yet +

No questions have been answered yet.

+

Ask something above and check back once a reply is posted.

+
`; return; } list.innerHTML = questions .map( (q) => ` -
-
- ${formatQuestionTooltip(q)} -
-

${escapeHtml(q.question)}

-

${escapeHtml(q.answer)}

-
`, +
+ #${String(q.id)} +

${escapeHtml(q.question)}

+

${escapeHtml(q.answer)}

+

${formatQuestionDates(q)}

+
`, ) .join(""); } export async function qaPage(outlet: HTMLElement) { - outlet.classList.add("qa-outlet"); const draft = readQuestionDraft(); - const placeholderQuestion = pickPlaceholder(PLACEHOLDER_QUESTIONS); outlet.innerHTML = ` -
- ${frostedBox( - ` -
+
+ ${frostedBox(`
-
+
+ Ask a Question -
- -
- ${draft.length}/200 - -
+ +

Press Enter to send. Press Shift+Enter for a new line.

+
+

+ ${draft.length}/200
-

-
Asked ... | Answered ... | Ratio ...
-
+
+

+ +
+
-
Loading answered questions...
-
- `, - "my-0 flex h-full min-h-0 flex-col", - )} +
Loading answered questions...
+ `)}
`; const form = document.getElementById("qa-form") as HTMLFormElement; @@ -113,46 +85,28 @@ export async function qaPage(outlet: HTMLElement) { "qa-submit", ) as HTMLButtonElement; const charCount = document.getElementById("char-count") as HTMLSpanElement; + const validation = document.getElementById( + "qa-validation", + ) as HTMLParagraphElement; const status = document.getElementById("qa-status") as HTMLParagraphElement; - const stats = document.getElementById("qa-stats") as HTMLDivElement; const list = document.getElementById("qa-list") as HTMLDivElement; let isSubmitting = false; let hasInteracted = draft.trim().length > 0; - let buttonResetTimer: number | null = null; - const defaultButtonText = "[SUBMIT]"; function setStatus(message: string, color: string) { status.textContent = message; status.style.color = color; } - function setStatsDisplay(nextStats: QuestionStats) { - stats.textContent = `Asked ${nextStats.asked} | Answered ${nextStats.answered} | Ratio ${formatRatio(nextStats)}`; - } - - function showButtonMessage(message: string, color: string, duration = 1600) { - if (buttonResetTimer !== null) window.clearTimeout(buttonResetTimer); - submitButton.textContent = message; - submitButton.style.color = color; - submitButton.classList.add("qa-button-message"); - buttonResetTimer = window.setTimeout(() => { - buttonResetTimer = null; - submitButton.textContent = defaultButtonText; - submitButton.style.color = ""; - submitButton.classList.remove("qa-button-message"); - if (!input.matches(":focus") && input.value.trim().length === 0) { - input.removeAttribute("aria-invalid"); - } - }, duration); - } - function updateValidation() { const trimmed = input.value.trim(); const remaining = 200 - input.value.length; charCount.textContent = `${input.value.length}/200`; if (trimmed.length === 0 && hasInteracted) { + validation.textContent = "Question cannot be empty."; + validation.style.color = "var(--light-red)"; input.setAttribute("aria-invalid", "true"); return false; } @@ -160,16 +114,13 @@ export async function qaPage(outlet: HTMLElement) { input.removeAttribute("aria-invalid"); if (remaining <= 20) { - charCount.style.color = - remaining === 0 - ? "var(--light-red)" - : remaining <= 5 - ? "var(--yellow)" - : "var(--dark-gray)"; + validation.textContent = `${remaining} characters left.`; + validation.style.color = + remaining <= 5 ? "var(--yellow)" : "var(--dark-gray)"; return true; } - charCount.style.color = "var(--dark-gray)"; + validation.textContent = ""; return true; } @@ -182,15 +133,14 @@ export async function qaPage(outlet: HTMLElement) { const questions = await getQuestions(); renderQuestions(list, questions); } catch { - showButtonMessage("[LOAD FAILED]", "var(--light-red)"); list.innerHTML = ` -
-

Load failed

+
+ Load failed

Failed to load answered questions.

-
`; + `; list.style.color = "var(--light-red)"; const retryButton = document.getElementById( @@ -202,15 +152,6 @@ export async function qaPage(outlet: HTMLElement) { } } - async function loadStats() { - try { - const nextStats = await getQuestionStats(); - setStatsDisplay(nextStats); - } catch { - stats.textContent = "Asked ? | Answered ? | Ratio ?"; - } - } - input.addEventListener("input", () => { hasInteracted = true; writeQuestionDraft(input.value); @@ -231,15 +172,13 @@ export async function qaPage(outlet: HTMLElement) { const question = input.value.trim(); hasInteracted = true; if (!updateValidation() || !question) { - setStatus("", "var(--dark-gray)"); - showButtonMessage("[EMPTY]", "var(--light-red)"); + setStatus("Write a question before submitting.", "var(--light-red)"); input.focus(); return; } isSubmitting = true; submitButton.disabled = true; - submitButton.textContent = "[SENDING]"; setStatus("Submitting...", "var(--light-gray)"); submitQuestion(question) @@ -247,8 +186,6 @@ export async function qaPage(outlet: HTMLElement) { input.value = ""; clearQuestionDraft(); hasInteracted = false; - submitButton.textContent = defaultButtonText; - submitButton.style.color = ""; updateValidation(); setStatus( "Question submitted! It will appear here once answered.", @@ -257,27 +194,17 @@ export async function qaPage(outlet: HTMLElement) { input.focus(); }) .catch((err: unknown) => { - const message = - err instanceof Error ? err.message : "Failed to submit question."; - if (message.includes("Too many questions")) { - showButtonMessage("[RATE LIMIT]", "var(--light-red)"); - setStatus(message, "var(--light-red)"); - } else { - showButtonMessage("[FAILED]", "var(--light-red)"); - setStatus("", "var(--dark-gray)"); - } + setStatus( + err instanceof Error ? err.message : "Failed to submit question.", + "var(--light-red)", + ); }) .finally(() => { isSubmitting = false; submitButton.disabled = false; - if (buttonResetTimer === null) { - submitButton.textContent = defaultButtonText; - submitButton.style.color = ""; - } }); }); updateValidation(); - await loadStats(); await loadQuestions(); } diff --git a/src/router.ts b/src/router.ts index 21bb97e..0a1059d 100644 --- a/src/router.ts +++ b/src/router.ts @@ -7,17 +7,14 @@ interface Route { pattern: RegExp; keys: string[]; handler: PageHandler; - title: string; } const routes: Route[] = []; let notFoundHandler: PageHandler | null = null; -let notFoundTitle = "404 - Jet Pham"; -export function route(path: string, title: string, handler: PageHandler) { +export function route(path: string, handler: PageHandler) { if (path === "*") { notFoundHandler = handler; - notFoundTitle = title; return; } const keys: string[] = []; @@ -25,13 +22,13 @@ export function route(path: string, title: string, handler: PageHandler) { keys.push(key); return "([^/]+)"; }); - routes.push({ pattern: new RegExp(`^${pattern}$`), keys, handler, title }); + routes.push({ pattern: new RegExp(`^${pattern}$`), keys, handler }); } export function navigate(path: string) { history.pushState(null, "", path); window.scrollTo({ top: 0, behavior: "auto" }); - void render(); + void render(true); } function updateNavState(path: string) { @@ -46,7 +43,7 @@ function updateNavState(path: string) { }); } -async function render() { +async function render(focusOutlet = false) { const path = location.pathname; const outlet = document.getElementById("outlet")!; updateNavState(path); @@ -60,7 +57,7 @@ async function render() { }); outlet.innerHTML = ""; await r.handler(outlet, params); - document.title = r.title; + if (focusOutlet) outlet.focus(); return; } } @@ -69,18 +66,17 @@ async function render() { if (notFoundHandler) { await notFoundHandler(outlet, {}); } - document.title = notFoundTitle; + if (focusOutlet) outlet.focus(); } export function initRouter() { - window.addEventListener("popstate", () => void render()); + window.addEventListener("popstate", () => void render(true)); document.addEventListener("click", (e) => { const anchor = (e.target as HTMLElement).closest("a"); if ( anchor?.origin === location.origin && !anchor.hash && - !anchor.hasAttribute("data-native-link") && !anchor.hasAttribute("download") && !anchor.hasAttribute("target") ) { diff --git a/src/styles/globals.css b/src/styles/globals.css index 76d299c..4e569ac 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -35,85 +35,40 @@ --white: #ffffff; } +/* Global BBS-style 80-character width constraint - responsive */ html { - min-height: 100%; - box-sizing: border-box; -} - -body { - min-height: 100vh; - min-height: 100dvh; - margin: 0; - overflow: hidden; - background-color: var(--black); - color: var(--white); + height: 100%; + width: min( + 80ch, + 100vw + ); /* 80 characters wide on desktop, full width on mobile */ + padding: 1rem; + margin: 0 auto; font-family: "IBM VGA", monospace; - font-size: 1.25rem; + font-size: 1.25rem; /* Smaller font size for mobile */ white-space: normal; line-height: 1; -} - -::selection { - background-color: var(--light-blue); - color: var(--black); -} - -*, -*::before, -*::after { - box-sizing: inherit; + box-sizing: border-box; } /* Desktop font size */ @media (min-width: 768px) { - body { + html { font-size: 1.5rem; } } -.page-frame { - height: 100vh; - height: 100dvh; - width: min(80ch, 100vw); - margin: 0 auto; - padding: 1rem; - display: grid; - grid-template-rows: auto 1fr auto; - gap: 2ch; +/* Apply CGA theme to body */ +body { + height: 100%; + background-color: var(--black); + color: var(--white); + margin: 0; + padding: 0; } #outlet { display: block; - min-height: 0; - overflow: hidden; - user-select: none; -} - -#outlet.qa-outlet { - overflow: hidden; -} - -#outlet:focus { - outline: none; -} - -.site-shell { - width: min(100%, 60%); - box-sizing: border-box; - margin: 0 auto; - user-select: text; -} - -.site-panel { - border: 2px solid var(--white); - background-color: rgba(0, 0, 0, 0.75); - backdrop-filter: blur(10px); - -webkit-backdrop-filter: blur(10px); - overflow: hidden; -} - -.site-region { - width: 100%; } /* Global focus ring styles for all tabbable elements */ @@ -144,10 +99,10 @@ a[aria-current="page"] { text-decoration: underline; } -a[aria-current="page"] { - color: var(--yellow); - background-color: transparent; - text-decoration: underline; +a[href^="http://"]::after, +a[href^="https://"]::after { + content: " [EXT]"; + color: var(--dark-gray); } .skip-link { @@ -166,62 +121,12 @@ a[aria-current="page"] { } /* Form inputs */ -.qa-input-wrap { - position: relative; - padding: 1ch; - background-color: rgba(0, 0, 0, 0.18); - border: 2px solid var(--white); - display: flex; - flex-direction: column; - gap: 1ch; -} - -.qa-page { - min-height: 0; - width: 100%; -} - -.qa-list-scroll { - padding-top: 1.5ch; - padding-bottom: 1.5ch; - scrollbar-gutter: stable; - overscroll-behavior: contain; - padding-right: 0.5ch; -} - -.qa-list-scroll::-webkit-scrollbar { - width: 1ch; -} - -.qa-list-scroll::-webkit-scrollbar-track { - background: transparent; -} - -.qa-list-scroll::-webkit-scrollbar-thumb { - background-color: var(--dark-gray); - border-left: 1px solid transparent; - border-right: 1px solid transparent; -} - -.qa-list-scroll::-webkit-scrollbar-thumb:hover { - background-color: var(--light-gray); -} - -.qa-stats { - color: var(--dark-gray); - text-align: center; -} - .qa-textarea { width: 100%; - min-height: 2lh; - background-color: transparent; - border: none; - caret-color: var(--white); - caret-shape: block; + background-color: var(--black); + border: 2px solid var(--white); color: var(--light-gray); - padding: 0; - overflow-y: auto; + padding: 1ch; resize: none; font-family: inherit; font-size: inherit; @@ -232,28 +137,14 @@ a[aria-current="page"] { color: var(--dark-gray); } -.qa-input-bar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 1ch; - pointer-events: auto; -} - -.qa-bar-text { - color: var(--dark-gray); -} - .qa-button { border: none; - padding: 0; + padding: 0.25ch 1ch; color: var(--yellow); background: transparent; font-family: inherit; font-size: inherit; - line-height: inherit; cursor: pointer; - pointer-events: auto; } .qa-button:hover { @@ -261,10 +152,6 @@ a[aria-current="page"] { color: var(--black); } -.qa-button-message:hover { - background: transparent; -} - .qa-button:disabled { color: var(--dark-gray); cursor: wait; @@ -275,46 +162,14 @@ a[aria-current="page"] { color: var(--dark-gray); } -.qa-meta { - margin-top: 0.5ch; +.qa-helper { + margin-top: 1ch; color: var(--dark-gray); } -.qa-item { - position: relative; -} - -.qa-list-item { - border-top: none; -} - -.qa-list-item + .qa-list-item { - margin-top: 1.5ch; - padding-top: 1.5ch; - border-top: 2px solid var(--white); -} - -.qa-list-label { - margin-bottom: 1ch; - color: var(--white); -} - -.qa-item-meta { - position: absolute; - top: 0; - right: 0; - display: none; - padding: 0.75ch 1ch; - background-color: rgba(0, 0, 0, 0.9); - box-shadow: inset 0 0 0 1px var(--dark-gray); - color: var(--light-gray); - z-index: 5; -} - -.qa-item:hover .qa-item-meta, -.qa-item:focus-within .qa-item-meta, -.qa-item:focus .qa-item-meta { - display: block; +.qa-meta { + margin-top: 0.5ch; + color: var(--dark-gray); } .qa-inline-action { @@ -334,23 +189,6 @@ a[aria-current="page"] { color: var(--black); } -.qa-textarea::selection { - background-color: var(--light-blue); - color: var(--black); -} - -.qa-textarea:focus { - outline: none; -} - -.site-footer-inner { - display: flex; - flex-wrap: wrap; - justify-content: center; - gap: 1ch; - color: var(--dark-gray); -} - .sr-only { position: absolute; width: 1px; diff --git a/vite.config.ts b/vite.config.ts index b9ef2d9..0b3d1b3 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,5 +1,4 @@ import { defineConfig } from "vite"; -import { execSync } from "node:child_process"; import tailwindcss from "@tailwindcss/vite"; import wasm from "vite-plugin-wasm"; import topLevelAwait from "vite-plugin-top-level-await"; @@ -7,12 +6,7 @@ import { viteSingleFile } from "vite-plugin-singlefile"; import ansi from "./vite-plugin-ansi"; import markdown from "./vite-plugin-markdown"; -const commitSha = execSync("git rev-parse HEAD", { encoding: "utf8" }).trim(); - export default defineConfig({ - define: { - __COMMIT_SHA__: JSON.stringify(commitSha), - }, plugins: [ ansi(), markdown(),