diff --git a/api/src/handlers.rs b/api/src/handlers.rs index c1f8832..9b87b72 100644 --- a/api/src/handlers.rs +++ b/api/src/handlers.rs @@ -5,7 +5,6 @@ use axum::http::{HeaderMap, StatusCode}; use axum::Json; use base64::Engine; use serde::{Deserialize, Serialize}; -use serde_json::Value; use crate::email; use crate::serve::AppState; @@ -258,92 +257,6 @@ fn extract_qa_reply(payload: &MtaHookPayload, expected_domain: &str) -> Option<( ) } -fn value_at_path<'a>(value: &'a Value, path: &[&str]) -> Option<&'a Value> { - let mut current = value; - for key in path { - current = current.get(*key)?; - } - Some(current) -} - -fn string_from_value(value: &Value) -> Option { - match value { - Value::String(s) => Some(s.clone()), - Value::Object(map) => map - .get("address") - .or_else(|| map.get("email")) - .or_else(|| map.get("value")) - .and_then(|v| v.as_str()) - .map(ToOwned::to_owned), - _ => None, - } -} - -fn recipients_from_value(value: Option<&Value>) -> Vec { - let Some(value) = value else { - return Vec::new(); - }; - - match value { - Value::Array(values) => values - .iter() - .filter_map(|v| string_from_value(v).map(Recipient::Address)) - .collect(), - _ => string_from_value(value) - .map(Recipient::Address) - .into_iter() - .collect(), - } -} - -fn string_at_paths(value: &Value, paths: &[&[&str]]) -> Option { - paths.iter().find_map(|path| { - value_at_path(value, path) - .and_then(|v| v.as_str()) - .map(ToOwned::to_owned) - }) -} - -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 { - if let Some(reply) = extract_qa_reply_from_message( - &recipients_from_value(value_at_path(message, &["envelope", "to"])), - expected_domain, - string_at_paths( - message, - &[ - &["message", "subject"], - &["message", "headers", "subject"], - &["headers", "subject"], - ], - ) - .as_deref(), - &string_at_paths(message, &[&["message", "contents"], &["contents"], &["raw_message"]]) - .unwrap_or_default(), - ) { - return Some(reply); - } - } - } - - extract_qa_reply_from_message( - &recipients_from_value(value_at_path(payload, &["envelope", "to"])), - expected_domain, - string_at_paths( - payload, - &[ - &["message", "subject"], - &["message", "headers", "subject"], - &["headers", "subject"], - ], - ) - .as_deref(), - &string_at_paths(payload, &[&["message", "contents"], &["contents"], &["raw_message"]]) - .unwrap_or_default(), - ) -} - fn extract_qa_reply_from_message( recipients: &[Recipient], expected_domain: &str, @@ -377,27 +290,14 @@ fn extract_qa_reply_from_message( pub async fn webhook( State(state): State>, headers: HeaderMap, - body: String, + Json(payload): Json, ) -> Result, (StatusCode, String)> { if !webhook_secret_matches(&headers, &state.webhook_secret) { eprintln!("Rejected webhook: invalid secret"); return Err((StatusCode::UNAUTHORIZED, "invalid secret".to_string())); } - let payload_value: Value = match serde_json::from_str(&body) { - Ok(payload) => payload, - Err(err) => { - eprintln!("Rejected webhook: invalid JSON payload: {err}; body={body}"); - return Ok(Json(MtaHookResponse { action: "accept" })); - } - }; - - let parsed_reply = serde_json::from_value::(payload_value.clone()) - .ok() - .and_then(|payload| extract_qa_reply(&payload, &state.qa_reply_domain)) - .or_else(|| extract_qa_reply_from_value(&payload_value, &state.qa_reply_domain)); - - if let Some((id, body)) = parsed_reply { + if let Some((id, body)) = extract_qa_reply(&payload, &state.qa_reply_domain) { let db = state .db .lock() @@ -413,16 +313,15 @@ pub async fn webhook( return Ok(Json(MtaHookResponse { action: "discard" })); } - eprintln!("Q&A webhook accepted payload without matched reply: {payload_value}"); + // No Q&A recipient matched — let Stalwart deliver normally Ok(Json(MtaHookResponse { action: "accept" })) } #[cfg(test)] mod tests { use axum::http::HeaderMap; - use serde_json::Value; - use super::{extract_qa_reply, extract_qa_reply_from_value, webhook_secret_matches, MtaHookPayload}; + use super::{extract_qa_reply, webhook_secret_matches, MtaHookPayload}; #[test] fn extracts_reply_from_current_stalwart_payload() { @@ -492,27 +391,4 @@ mod tests { assert!(webhook_secret_matches(&headers, "topsecret")); } - - #[test] - fn extracts_reply_from_value_with_string_recipient() { - let payload: Value = serde_json::from_str( - r#"{ - "envelope": { - "to": "qa@extremist.software" - }, - "message": { - "headers": { - "subject": "Re: 9 - hi" - }, - "contents": "Answer body" - } - }"#, - ) - .unwrap(); - - assert_eq!( - extract_qa_reply_from_value(&payload, "extremist.software"), - Some((9, "Answer body".to_string())) - ); - } } diff --git a/index.html b/index.html index 362f098..f78d6bc 100644 --- a/index.html +++ b/index.html @@ -6,130 +6,95 @@ Jet Pham - Software Extremist - + - + - + - + - - + + - - - + + -
+
diff --git a/src/lib/qa.ts b/src/lib/qa.ts deleted file mode 100644 index c4b6343..0000000 --- a/src/lib/qa.ts +++ /dev/null @@ -1,62 +0,0 @@ -const DRAFT_KEY = "qa-draft"; -const relativeTime = new Intl.RelativeTimeFormat(undefined, { - numeric: "auto", -}); - -function getValidDate(dateString: string): Date | null { - const date = new Date(dateString); - return Number.isNaN(date.getTime()) ? null : date; -} - -export function readQuestionDraft(): string { - try { - return localStorage.getItem(DRAFT_KEY) ?? ""; - } catch { - return ""; - } -} - -export function writeQuestionDraft(value: string) { - try { - localStorage.setItem(DRAFT_KEY, value); - } catch { - // Ignore storage failures. - } -} - -export function clearQuestionDraft() { - try { - localStorage.removeItem(DRAFT_KEY); - } catch { - // Ignore storage failures. - } -} - -export function formatExactTimestamp(dateString: string): string { - const date = getValidDate(dateString); - if (!date) return dateString; - - return new Intl.DateTimeFormat(undefined, { - dateStyle: "medium", - timeStyle: "short", - }).format(date); -} - -export function formatRelativeTimestamp(dateString: string): string { - const date = getValidDate(dateString); - if (!date) return dateString; - - const diffMs = date.getTime() - Date.now(); - const diffMinutes = Math.round(diffMs / 60000); - const diffHours = Math.round(diffMs / 3600000); - const diffDays = Math.round(diffMs / 86400000); - - 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"); - - return new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }).format( - date, - ); -} diff --git a/src/pages/qa.ts b/src/pages/qa.ts index 25ee47d..190b4ca 100644 --- a/src/pages/qa.ts +++ b/src/pages/qa.ts @@ -1,11 +1,4 @@ -import { getQuestions, submitQuestion, type Question } from "~/lib/api"; -import { - clearQuestionDraft, - formatExactTimestamp, - formatRelativeTimestamp, - readQuestionDraft, - writeQuestionDraft, -} from "~/lib/qa"; +import { getQuestions, submitQuestion } from "~/lib/api"; import { frostedBox } from "~/components/frosted-box"; function escapeHtml(str: string): string { @@ -14,197 +7,81 @@ function escapeHtml(str: string): string { 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); - - return ` - Asked ${escapeHtml(asked)} - · - Answered ${escapeHtml(answered)}`; -} - -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.

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

${escapeHtml(q.question)}

-

${escapeHtml(q.answer)}

-

${formatQuestionDates(q)}

-
`, - ) - .join(""); -} - export async function qaPage(outlet: HTMLElement) { - const draft = readQuestionDraft(); - outlet.innerHTML = `
${frostedBox(` -
+
Ask a Question - -

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

-
-

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

- + placeholder="Type your question..."> +
+ 0/200 +
+

-
Loading answered questions...
+
Loading...
`)}
`; const form = document.getElementById("qa-form") as HTMLFormElement; const input = document.getElementById("qa-input") as HTMLTextAreaElement; - const submitButton = document.getElementById( - "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 list = document.getElementById("qa-list") as HTMLDivElement; - - let isSubmitting = false; - let hasInteracted = draft.trim().length > 0; - - function setStatus(message: string, color: string) { - status.textContent = message; - status.style.color = color; - } - - 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; - } - - input.removeAttribute("aria-invalid"); - - if (remaining <= 20) { - validation.textContent = `${remaining} characters left.`; - validation.style.color = - remaining <= 5 ? "var(--yellow)" : "var(--dark-gray)"; - return true; - } - - validation.textContent = ""; - return true; - } - - async function loadQuestions() { - list.textContent = "Loading answered questions..."; - list.style.color = "var(--light-gray)"; - list.style.textAlign = "left"; - - try { - const questions = await getQuestions(); - renderQuestions(list, questions); - } catch { - list.innerHTML = ` -
- Load failed -

Failed to load answered questions.

-

- -

-
`; - list.style.color = "var(--light-red)"; - - const retryButton = document.getElementById( - "qa-retry", - ) as HTMLButtonElement | null; - retryButton?.addEventListener("click", () => { - void loadQuestions(); - }); - } - } + const charCount = document.getElementById("char-count")!; + const status = document.getElementById("qa-status")!; + const list = document.getElementById("qa-list")!; input.addEventListener("input", () => { - hasInteracted = true; - writeQuestionDraft(input.value); - updateValidation(); - }); - - input.addEventListener("keydown", (e) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - form.requestSubmit(); - } + charCount.textContent = `${input.value.length}/200`; }); form.addEventListener("submit", (e) => { e.preventDefault(); - if (isSubmitting) return; - const question = input.value.trim(); - hasInteracted = true; - if (!updateValidation() || !question) { - setStatus("Write a question before submitting.", "var(--light-red)"); - input.focus(); - return; - } + if (!question) return; - isSubmitting = true; - submitButton.disabled = true; - setStatus("Submitting...", "var(--light-gray)"); + status.textContent = "Submitting..."; + status.style.color = "var(--light-gray)"; submitQuestion(question) .then(() => { input.value = ""; - clearQuestionDraft(); - hasInteracted = false; - updateValidation(); - setStatus( - "Question submitted! It will appear here once answered.", - "var(--light-green)", - ); - input.focus(); + charCount.textContent = "0/200"; + status.textContent = "Question submitted! It will appear here once answered."; + status.style.color = "var(--light-green)"; }) .catch((err: unknown) => { - setStatus( - err instanceof Error ? err.message : "Failed to submit question.", - "var(--light-red)", - ); - }) - .finally(() => { - isSubmitting = false; - submitButton.disabled = false; + status.textContent = err instanceof Error ? err.message : "Failed to submit question."; + status.style.color = "var(--light-red)"; }); }); - updateValidation(); - await loadQuestions(); + try { + const questions = await getQuestions(); + if (questions.length === 0) { + list.textContent = "No questions answered yet."; + list.style.color = "var(--dark-gray)"; + } else { + list.innerHTML = questions + .map( + (q) => ` +
+ #${String(q.id)} +

${escapeHtml(q.question)}

+

${escapeHtml(q.answer)}

+

+ Asked ${q.created_at} · Answered ${q.answered_at} +

+
`, + ) + .join(""); + } + } catch { + list.textContent = "Failed to load questions."; + list.style.color = "var(--light-red)"; + list.style.textAlign = "center"; + } } diff --git a/src/router.ts b/src/router.ts index 0a1059d..a78b093 100644 --- a/src/router.ts +++ b/src/router.ts @@ -27,26 +27,12 @@ export function route(path: string, handler: PageHandler) { export function navigate(path: string) { history.pushState(null, "", path); - window.scrollTo({ top: 0, behavior: "auto" }); - void render(true); + void render(); } -function updateNavState(path: string) { - const navLinks = - document.querySelectorAll("[data-nav-link]"); - navLinks.forEach((link) => { - if (link.pathname === path) { - link.setAttribute("aria-current", "page"); - } else { - link.removeAttribute("aria-current"); - } - }); -} - -async function render(focusOutlet = false) { +async function render() { const path = location.pathname; const outlet = document.getElementById("outlet")!; - updateNavState(path); for (const r of routes) { const match = path.match(r.pattern); @@ -57,7 +43,6 @@ async function render(focusOutlet = false) { }); outlet.innerHTML = ""; await r.handler(outlet, params); - if (focusOutlet) outlet.focus(); return; } } @@ -66,20 +51,14 @@ async function render(focusOutlet = false) { if (notFoundHandler) { await notFoundHandler(outlet, {}); } - if (focusOutlet) outlet.focus(); } 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("download") && - !anchor.hasAttribute("target") - ) { + if (anchor?.origin === location.origin && !anchor.hasAttribute("download")) { e.preventDefault(); navigate(anchor.pathname); } diff --git a/src/styles/globals.css b/src/styles/globals.css index 4e569ac..df9432a 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -9,232 +9,144 @@ } :root { - --font-sans: - "IBM VGA", ui-monospace, "SF Mono", "Monaco", "Inconsolata", "Roboto Mono", - "Source Code Pro", monospace; - --font-mono: - "IBM VGA", ui-monospace, "SF Mono", "Monaco", "Inconsolata", "Roboto Mono", - "Source Code Pro", monospace; + --font-sans: "IBM VGA", ui-monospace, "SF Mono", "Monaco", "Inconsolata", "Roboto Mono", "Source Code Pro", monospace; + --font-mono: "IBM VGA", ui-monospace, "SF Mono", "Monaco", "Inconsolata", "Roboto Mono", "Source Code Pro", monospace; /* 16-color palette */ --black: #000000; - --blue: #0000aa; - --green: #00aa00; - --cyan: #00aaaa; - --red: #aa0000; - --magenta: #aa00aa; - --brown: #aa5500; - --light-gray: #aaaaaa; + --blue: #0000AA; + --green: #00AA00; + --cyan: #00AAAA; + --red: #AA0000; + --magenta: #AA00AA; + --brown: #AA5500; + --light-gray: #AAAAAA; --dark-gray: #555555; - --light-blue: #5555ff; - --light-green: #55ff55; - --light-cyan: #55ffff; - --light-red: #ff5555; - --light-magenta: #ff55ff; - --yellow: #ffff55; - --white: #ffffff; + --light-blue: #5555FF; + --light-green: #55FF55; + --light-cyan: #55FFFF; + --light-red: #FF5555; + --light-magenta: #FF55FF; + --yellow: #FFFF55; + --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; - font-family: "IBM VGA", monospace; - font-size: 1.25rem; /* Smaller font size for mobile */ - white-space: normal; - line-height: 1; - box-sizing: border-box; -} - -/* Desktop font size */ -@media (min-width: 768px) { + + /* Global BBS-style 80-character width constraint - responsive */ html { - font-size: 1.5rem; + 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; /* Smaller font size for mobile */ + white-space: normal; + line-height: 1; + box-sizing: border-box; } -} -/* Apply CGA theme to body */ -body { - height: 100%; - background-color: var(--black); - color: var(--white); - margin: 0; - padding: 0; + /* Desktop font size */ + @media (min-width: 768px) { + html { + font-size: 1.5rem; + } + } + + /* Apply CGA theme to body */ + body { + height: 100%; + background-color: var(--black); + color: var(--white); + margin: 0; + padding: 0; + } + + /* Global focus ring styles for all tabbable elements */ + button:focus, + a:focus, + input:focus, + textarea:focus, + select:focus, + [tabindex]:focus, + [contenteditable]:focus { + outline: 2px solid white; + outline-offset: -2px; + border-radius: 0; + } + + /* Link styles - blue without underline */ + a { + color: var(--light-blue); + text-decoration: none; } + + a:hover { + background-color: var(--light-blue); + color: var(--black); + } -#outlet { - display: block; -} + /* Form inputs */ + .qa-textarea { + width: 100%; + background-color: var(--black); + border: 2px solid var(--white); + color: var(--light-gray); + padding: 1ch; + resize: none; + font-family: inherit; + font-size: inherit; + line-height: inherit; + } -/* Global focus ring styles for all tabbable elements */ -button:focus, -a:focus, -input:focus, -textarea:focus, -select:focus, -[tabindex]:focus, -[contenteditable]:focus { - outline: 2px solid white; - outline-offset: -2px; - border-radius: 0; -} + .qa-button { + border: none; + padding: 0.25ch 1ch; + color: var(--yellow); + background: transparent; + font-family: inherit; + font-size: inherit; + cursor: pointer; + } -/* Link styles - blue without underline */ -a { - color: var(--light-blue); - text-decoration: none; - text-underline-offset: 0.2ch; -} + .qa-button:hover { + background-color: var(--yellow); + color: var(--black); + } -a:hover, -a:focus-visible, -a[aria-current="page"] { - background-color: var(--light-blue); - color: var(--black); - text-decoration: underline; -} + /* Project markdown content */ + .project-content h2 { + color: var(--light-cyan); + margin-top: 2ch; + margin-bottom: 1ch; + } -a[href^="http://"]::after, -a[href^="https://"]::after { - content: " [EXT]"; - color: var(--dark-gray); -} + .project-content h3 { + color: var(--light-green); + margin-top: 1.5ch; + margin-bottom: 0.5ch; + } -.skip-link { - position: absolute; - left: 1rem; - top: 0; - transform: translateY(-150%); - padding: 0.5ch 1ch; - background: var(--yellow); - color: var(--black); - z-index: 20; -} + .project-content p { + margin-bottom: 1ch; + } -.skip-link:focus { - transform: translateY(1rem); -} + .project-content ul, + .project-content ol { + margin-left: 2ch; + margin-bottom: 1ch; + } -/* Form inputs */ -.qa-textarea { - width: 100%; - background-color: var(--black); - border: 2px solid var(--white); - color: var(--light-gray); - padding: 1ch; - resize: none; - font-family: inherit; - font-size: inherit; - line-height: inherit; -} + .project-content code { + color: var(--yellow); + } -.qa-textarea::placeholder { - color: var(--dark-gray); -} + .project-content pre { + border: 2px solid var(--dark-gray); + padding: 1ch; + overflow-x: auto; + margin-bottom: 1ch; + } -.qa-button { - border: none; - padding: 0.25ch 1ch; - color: var(--yellow); - background: transparent; - font-family: inherit; - font-size: inherit; - cursor: pointer; -} + .project-content a { + color: var(--light-blue); + } -.qa-button:hover { - background-color: var(--yellow); - color: var(--black); -} - -.qa-button:disabled { - color: var(--dark-gray); - cursor: wait; -} - -.qa-button:disabled:hover { - background: transparent; - color: var(--dark-gray); -} - -.qa-helper { - margin-top: 1ch; - color: var(--dark-gray); -} - -.qa-meta { - margin-top: 0.5ch; - color: var(--dark-gray); -} - -.qa-inline-action { - border: none; - background: transparent; - color: var(--light-blue); - padding: 0; - font: inherit; - cursor: pointer; - text-decoration: underline; - text-underline-offset: 0.2ch; -} - -.qa-inline-action:hover, -.qa-inline-action:focus-visible { - background-color: var(--light-blue); - color: var(--black); -} - -.sr-only { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; -} - -/* Project markdown content */ -.project-content h2 { - color: var(--light-cyan); - margin-top: 2ch; - margin-bottom: 1ch; -} - -.project-content h3 { - color: var(--light-green); - margin-top: 1.5ch; - margin-bottom: 0.5ch; -} - -.project-content p { - margin-bottom: 1ch; -} - -.project-content ul, -.project-content ol { - margin-left: 2ch; - margin-bottom: 1ch; -} - -.project-content code { - color: var(--yellow); -} - -.project-content pre { - border: 2px solid var(--dark-gray); - padding: 1ch; - overflow-x: auto; - margin-bottom: 1ch; -} - -.project-content a { - color: var(--light-blue); -}