diff --git a/api/src/handlers.rs b/api/src/handlers.rs index c1f8832..36e8828 100644 --- a/api/src/handlers.rs +++ b/api/src/handlers.rs @@ -19,6 +19,12 @@ 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> { @@ -51,6 +57,29 @@ 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, @@ -191,10 +220,12 @@ 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(""); + .unwrap_or("") + .trim(); if header_secret == expected_secret { return true; } @@ -224,7 +255,29 @@ fn webhook_secret_matches(headers: &HeaderMap, expected_secret: &str) -> bool { None => return false, }; - password == expected_secret + 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:?}" + ) } fn extract_qa_reply(payload: &MtaHookPayload, expected_domain: &str) -> Option<(i64, String)> { @@ -304,6 +357,21 @@ 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 { @@ -318,6 +386,7 @@ 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(), @@ -338,6 +407,7 @@ 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(), @@ -380,7 +450,11 @@ pub async fn webhook( body: String, ) -> Result, (StatusCode, String)> { if !webhook_secret_matches(&headers, &state.webhook_secret) { - eprintln!("Rejected webhook: invalid secret"); + eprintln!( + "Rejected webhook: invalid secret; expected_len={}; {}", + state.webhook_secret.len(), + webhook_secret_debug(&headers) + ); return Err((StatusCode::UNAUTHORIZED, "invalid secret".to_string())); } @@ -515,4 +589,28 @@ 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 7d7afdf..f381b5c 100644 --- a/api/src/serve.rs +++ b/api/src/serve.rs @@ -52,6 +52,7 @@ 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 362f098..51ddb40 100644 --- a/index.html +++ b/index.html @@ -106,30 +106,20 @@ 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 new file mode 100644 index 0000000..e69de29 diff --git a/public/ssh.txt b/public/ssh.txt new file mode 100644 index 0000000..8de92cb --- /dev/null +++ b/public/ssh.txt @@ -0,0 +1,2 @@ +# Jet Pham SSH fingerprints +ssh-ed25519 SHA256:Ziw7a2bUA1ew4AFQLB8rk9G3l9I4/eRClf9OJMLMLUA diff --git a/src/components/frosted-box.ts b/src/components/frosted-box.ts index 86b8f9c..3254ebc 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 9193a96..a266ba5 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -1,5 +1,7 @@ /// +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 79b458a..9c51ba7 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -6,12 +6,118 @@ 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", @@ -19,7 +125,8 @@ 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 c4b6343..3fe2830 100644 --- a/src/lib/qa.ts +++ b/src/lib/qa.ts @@ -1,4 +1,5 @@ const DRAFT_KEY = "qa-draft"; +const LAST_PLACEHOLDER_KEY = "qa-last-placeholder"; const relativeTime = new Intl.RelativeTimeFormat(undefined, { numeric: "auto", }); @@ -32,21 +33,19 @@ export function clearQuestionDraft() { } } -export function formatExactTimestamp(dateString: string): string { +export function formatDateOnly(dateString: string): string { const date = getValidDate(dateString); if (!date) return dateString; - return new Intl.DateTimeFormat(undefined, { - dateStyle: "medium", - timeStyle: "short", - }).format(date); + return date.toISOString().slice(0, 10); } -export function formatRelativeTimestamp(dateString: string): string { +export function formatFriendlyDate(dateString: string): string { const date = getValidDate(dateString); if (!date) return dateString; - const diffMs = date.getTime() - Date.now(); + const now = Date.now(); + const diffMs = date.getTime() - now; const diffMinutes = Math.round(diffMs / 60000); const diffHours = Math.round(diffMs / 3600000); const diffDays = Math.round(diffMs / 86400000); @@ -54,9 +53,36 @@ export function formatRelativeTimestamp(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) < 30) return relativeTime.format(diffDays, "day"); + if (Math.abs(diffDays) < 14) return relativeTime.format(diffDays, "day"); - return new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }).format( - date, - ); + 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]!; } diff --git a/src/lib/site.ts b/src/lib/site.ts new file mode 100644 index 0000000..bc699f8 --- /dev/null +++ b/src/lib/site.ts @@ -0,0 +1,47 @@ +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 55c70e7..d6d49dd 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,13 +1,14 @@ 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("/", homePage); -route("/qa", qaPage); -route("*", notFoundPage); +route("/", "Jet Pham - Home", homePage); +route("/qa", "Jet Pham - Q+A", qaPage); +route("*", "404 - Jet Pham", notFoundPage); try { await init(); @@ -17,3 +18,4 @@ try { } initRouter(); +renderFooter(); diff --git a/src/pages/home.ts b/src/pages/home.ts index f6befdd..66aa238 100644 --- a/src/pages/home.ts +++ b/src/pages/home.ts @@ -2,8 +2,9 @@ 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(`
@@ -22,20 +23,43 @@ 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 0534b6f..40fa4f0 100644 --- a/src/pages/not-found.ts +++ b/src/pages/not-found.ts @@ -1,8 +1,9 @@ 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 25ee47d..e1b2152 100644 --- a/src/pages/qa.ts +++ b/src/pages/qa.ts @@ -1,82 +1,110 @@ -import { getQuestions, submitQuestion, type Question } from "~/lib/api"; +import { + getQuestions, + getQuestionStats, + submitQuestion, + type Question, + type QuestionStats, +} from "~/lib/api"; import { clearQuestionDraft, - formatExactTimestamp, - formatRelativeTimestamp, + formatDateOnly, + pickPlaceholder, 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 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); +function formatQuestionTooltip(question: Question): string { + const askedExact = formatDateOnly(question.created_at); + const answeredExact = formatDateOnly(question.answered_at); return ` - Asked ${escapeHtml(asked)} - · - Answered ${escapeHtml(answered)}`; +

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)}%`; } function renderQuestions(list: HTMLElement, questions: Question[]) { if (questions.length === 0) { list.innerHTML = ` -
- No answers yet -

No questions have been answered yet.

-

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

-
`; +
+

No answers yet

+

...

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

${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 +
- -

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

-
-

- ${draft.length}/200 +
+ +
-
-

- -
-
+

+
Asked ... | Answered ... | Ratio ...
+
-
Loading answered questions...
- `)} +
Loading answered questions...
+
+ `, + "my-0 flex h-full min-h-0 flex-col", + )}
`; const form = document.getElementById("qa-form") as HTMLFormElement; @@ -85,28 +113,46 @@ 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; } @@ -114,13 +160,16 @@ export async function qaPage(outlet: HTMLElement) { input.removeAttribute("aria-invalid"); if (remaining <= 20) { - validation.textContent = `${remaining} characters left.`; - validation.style.color = - remaining <= 5 ? "var(--yellow)" : "var(--dark-gray)"; + charCount.style.color = + remaining === 0 + ? "var(--light-red)" + : remaining <= 5 + ? "var(--yellow)" + : "var(--dark-gray)"; return true; } - validation.textContent = ""; + charCount.style.color = "var(--dark-gray)"; return true; } @@ -133,14 +182,15 @@ 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( @@ -152,6 +202,15 @@ 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); @@ -172,13 +231,15 @@ export async function qaPage(outlet: HTMLElement) { const question = input.value.trim(); hasInteracted = true; if (!updateValidation() || !question) { - setStatus("Write a question before submitting.", "var(--light-red)"); + setStatus("", "var(--dark-gray)"); + showButtonMessage("[EMPTY]", "var(--light-red)"); input.focus(); return; } isSubmitting = true; submitButton.disabled = true; + submitButton.textContent = "[SENDING]"; setStatus("Submitting...", "var(--light-gray)"); submitQuestion(question) @@ -186,6 +247,8 @@ 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.", @@ -194,17 +257,27 @@ export async function qaPage(outlet: HTMLElement) { input.focus(); }) .catch((err: unknown) => { - setStatus( - err instanceof Error ? err.message : "Failed to submit question.", - "var(--light-red)", - ); + 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)"); + } }) .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 0a1059d..21bb97e 100644 --- a/src/router.ts +++ b/src/router.ts @@ -7,14 +7,17 @@ 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, handler: PageHandler) { +export function route(path: string, title: string, handler: PageHandler) { if (path === "*") { notFoundHandler = handler; + notFoundTitle = title; return; } const keys: string[] = []; @@ -22,13 +25,13 @@ export function route(path: string, handler: PageHandler) { keys.push(key); return "([^/]+)"; }); - routes.push({ pattern: new RegExp(`^${pattern}$`), keys, handler }); + routes.push({ pattern: new RegExp(`^${pattern}$`), keys, handler, title }); } export function navigate(path: string) { history.pushState(null, "", path); window.scrollTo({ top: 0, behavior: "auto" }); - void render(true); + void render(); } function updateNavState(path: string) { @@ -43,7 +46,7 @@ function updateNavState(path: string) { }); } -async function render(focusOutlet = false) { +async function render() { const path = location.pathname; const outlet = document.getElementById("outlet")!; updateNavState(path); @@ -57,7 +60,7 @@ async function render(focusOutlet = false) { }); outlet.innerHTML = ""; await r.handler(outlet, params); - if (focusOutlet) outlet.focus(); + document.title = r.title; return; } } @@ -66,17 +69,18 @@ async function render(focusOutlet = false) { if (notFoundHandler) { await notFoundHandler(outlet, {}); } - if (focusOutlet) outlet.focus(); + document.title = notFoundTitle; } export function initRouter() { - window.addEventListener("popstate", () => void render(true)); + window.addEventListener("popstate", () => void render()); 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 4e569ac..ac2cc7b 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -35,40 +35,80 @@ --white: #ffffff; } -/* Global BBS-style 80-character width constraint - responsive */ html { - height: 100%; - width: min( - 80ch, - 100vw - ); /* 80 characters wide on desktop, full width on mobile */ - padding: 1rem; - margin: 0 auto; + 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); font-family: "IBM VGA", monospace; - font-size: 1.25rem; /* Smaller font size for mobile */ + font-size: 1.25rem; white-space: normal; line-height: 1; - box-sizing: border-box; +} + +*, +*::before, +*::after { + box-sizing: inherit; } /* Desktop font size */ @media (min-width: 768px) { - html { + body { font-size: 1.5rem; } } -/* Apply CGA theme to body */ -body { - height: 100%; - background-color: var(--black); - color: var(--white); - margin: 0; - padding: 0; +.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; } #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%, 66.666667%); + 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 */ @@ -99,10 +139,10 @@ a[aria-current="page"] { text-decoration: underline; } -a[href^="http://"]::after, -a[href^="https://"]::after { - content: " [EXT]"; - color: var(--dark-gray); +a[aria-current="page"] { + color: var(--yellow); + background-color: transparent; + text-decoration: underline; } .skip-link { @@ -121,12 +161,59 @@ a[href^="https://"]::after { } /* Form inputs */ +.qa-input-wrap { + position: relative; + padding: 1ch; + background-color: rgba(0, 0, 0, 0.18); + border: 2px solid var(--white); +} + +.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%; - background-color: var(--black); - border: 2px solid var(--white); + background-color: transparent; + border: none; + caret-color: var(--white); color: var(--light-gray); - padding: 1ch; + padding: 0; + padding-right: 14ch; + padding-bottom: 4ch; + overflow-y: auto; resize: none; font-family: inherit; font-size: inherit; @@ -137,6 +224,24 @@ a[href^="https://"]::after { color: var(--dark-gray); } +.qa-input-bar { + position: absolute; + right: 2px; + bottom: 2px; + left: 2px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1ch; + padding: 0 1ch 1ch; + pointer-events: none; + background: linear-gradient(to top, rgba(0, 0, 0, 0.82), transparent); +} + +.qa-bar-text { + color: var(--dark-gray); +} + .qa-button { border: none; padding: 0.25ch 1ch; @@ -145,6 +250,7 @@ a[href^="https://"]::after { font-family: inherit; font-size: inherit; cursor: pointer; + pointer-events: auto; } .qa-button:hover { @@ -152,6 +258,10 @@ a[href^="https://"]::after { color: var(--black); } +.qa-button-message:hover { + background: transparent; +} + .qa-button:disabled { color: var(--dark-gray); cursor: wait; @@ -162,16 +272,48 @@ a[href^="https://"]::after { color: var(--dark-gray); } -.qa-helper { - margin-top: 1ch; - color: var(--dark-gray); -} - .qa-meta { margin-top: 0.5ch; 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-inline-action { border: none; background: transparent; @@ -189,6 +331,23 @@ a[href^="https://"]::after { 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 0b3d1b3..b9ef2d9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,4 +1,5 @@ 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"; @@ -6,7 +7,12 @@ 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(),