Compare commits
2 commits
6a652ed4f3
...
691394445a
| Author | SHA1 | Date | |
|---|---|---|---|
| 691394445a | |||
| 1149597139 |
6 changed files with 693 additions and 240 deletions
|
|
@ -5,6 +5,7 @@ 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;
|
||||
|
|
@ -257,6 +258,92 @@ 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<String> {
|
||||
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<Recipient> {
|
||||
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<String> {
|
||||
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,
|
||||
|
|
@ -290,14 +377,27 @@ fn extract_qa_reply_from_message(
|
|||
pub async fn webhook(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<MtaHookPayload>,
|
||||
body: String,
|
||||
) -> Result<Json<MtaHookResponse>, (StatusCode, String)> {
|
||||
if !webhook_secret_matches(&headers, &state.webhook_secret) {
|
||||
eprintln!("Rejected webhook: invalid secret");
|
||||
return Err((StatusCode::UNAUTHORIZED, "invalid secret".to_string()));
|
||||
}
|
||||
|
||||
if let Some((id, body)) = extract_qa_reply(&payload, &state.qa_reply_domain) {
|
||||
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::<MtaHookPayload>(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 {
|
||||
let db = state
|
||||
.db
|
||||
.lock()
|
||||
|
|
@ -313,15 +413,16 @@ pub async fn webhook(
|
|||
return Ok(Json(MtaHookResponse { action: "discard" }));
|
||||
}
|
||||
|
||||
// No Q&A recipient matched — let Stalwart deliver normally
|
||||
eprintln!("Q&A webhook accepted payload without matched reply: {payload_value}");
|
||||
Ok(Json(MtaHookResponse { action: "accept" }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use axum::http::HeaderMap;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{extract_qa_reply, webhook_secret_matches, MtaHookPayload};
|
||||
use super::{extract_qa_reply, extract_qa_reply_from_value, webhook_secret_matches, MtaHookPayload};
|
||||
|
||||
#[test]
|
||||
fn extracts_reply_from_current_stalwart_payload() {
|
||||
|
|
@ -391,4 +492,27 @@ 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()))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
163
index.html
163
index.html
|
|
@ -6,95 +6,130 @@
|
|||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="apple-mobile-web-app-title" content="Jet Pham" />
|
||||
<title>Jet Pham - Software Extremist</title>
|
||||
<meta name="description" content="Jet Pham's personal website. Software extremist." />
|
||||
<meta
|
||||
name="description"
|
||||
content="Jet Pham's personal website. Software extremist."
|
||||
/>
|
||||
<link rel="canonical" href="https://jetpham.com/" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/apple-icon.png" />
|
||||
<link rel="preload" href="/Web437_IBM_VGA_8x16.woff" as="font" type="font/woff" crossorigin />
|
||||
<link
|
||||
rel="preload"
|
||||
href="/Web437_IBM_VGA_8x16.woff"
|
||||
as="font"
|
||||
type="font/woff"
|
||||
crossorigin
|
||||
/>
|
||||
<meta property="og:title" content="Jet Pham - Software Extremist" />
|
||||
<meta property="og:description" content="Jet Pham's personal website. Software extremist." />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Jet Pham's personal website. Software extremist."
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://jetpham.com/" />
|
||||
<meta property="og:site_name" content="Jet Pham" />
|
||||
<meta property="og:image" content="https://jetpham.com/web-app-manifest-512x512.png" />
|
||||
<meta
|
||||
property="og:image"
|
||||
content="https://jetpham.com/web-app-manifest-512x512.png"
|
||||
/>
|
||||
<meta property="og:image:width" content="512" />
|
||||
<meta property="og:image:height" content="512" />
|
||||
<meta property="og:image:alt" content="Jet Pham" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:title" content="Jet Pham - Software Extremist" />
|
||||
<meta name="twitter:description" content="Jet Pham's personal website. Software extremist." />
|
||||
<meta name="twitter:image" content="https://jetpham.com/web-app-manifest-512x512.png" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Jet Pham's personal website. Software extremist."
|
||||
/>
|
||||
<meta
|
||||
name="twitter:image"
|
||||
content="https://jetpham.com/web-app-manifest-512x512.png"
|
||||
/>
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Person",
|
||||
"name": "Jet Pham",
|
||||
"givenName": "Jet",
|
||||
"familyName": "Pham",
|
||||
"description": "Software extremist.",
|
||||
"url": "https://jetpham.com",
|
||||
"jobTitle": "Software Extremist",
|
||||
"hasOccupation": {
|
||||
"@type": "Occupation",
|
||||
"name": "Hacker"
|
||||
},
|
||||
"email": "jet@extremist.software",
|
||||
"image": "https://jetpham.com/jet.svg",
|
||||
"alumniOf": {
|
||||
"@type": "CollegeOrUniversity",
|
||||
"name": "University of San Francisco",
|
||||
"url": "https://www.usfca.edu"
|
||||
},
|
||||
"hasCredential": {
|
||||
"@type": "EducationalOccupationalCredential",
|
||||
"credentialCategory": "degree",
|
||||
"name": "Bachelor of Science in Computer Science"
|
||||
},
|
||||
"homeLocation": {
|
||||
"@type": "City",
|
||||
"name": "San Francisco, CA"
|
||||
},
|
||||
"workLocation": {
|
||||
"@type": "City",
|
||||
"name": "San Francisco, CA"
|
||||
},
|
||||
"memberOf": {
|
||||
"@type": "Organization",
|
||||
"name": "Noisebridge",
|
||||
"url": "https://www.noisebridge.net"
|
||||
},
|
||||
"affiliation": {
|
||||
"@type": "Organization",
|
||||
"name": "Noisebridge",
|
||||
"url": "https://www.noisebridge.net"
|
||||
},
|
||||
"sameAs": [
|
||||
"https://github.com/jetpham",
|
||||
"https://x.com/exmistsoftware",
|
||||
"https://bsky.app/profile/extremist.software",
|
||||
"https://git.extremist.software"
|
||||
]
|
||||
}
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Person",
|
||||
"name": "Jet Pham",
|
||||
"givenName": "Jet",
|
||||
"familyName": "Pham",
|
||||
"description": "Software extremist.",
|
||||
"url": "https://jetpham.com",
|
||||
"jobTitle": "Software Extremist",
|
||||
"hasOccupation": {
|
||||
"@type": "Occupation",
|
||||
"name": "Hacker"
|
||||
},
|
||||
"email": "jet@extremist.software",
|
||||
"image": "https://jetpham.com/jet.svg",
|
||||
"alumniOf": {
|
||||
"@type": "CollegeOrUniversity",
|
||||
"name": "University of San Francisco",
|
||||
"url": "https://www.usfca.edu"
|
||||
},
|
||||
"hasCredential": {
|
||||
"@type": "EducationalOccupationalCredential",
|
||||
"credentialCategory": "degree",
|
||||
"name": "Bachelor of Science in Computer Science"
|
||||
},
|
||||
"homeLocation": {
|
||||
"@type": "City",
|
||||
"name": "San Francisco, CA"
|
||||
},
|
||||
"workLocation": {
|
||||
"@type": "City",
|
||||
"name": "San Francisco, CA"
|
||||
},
|
||||
"memberOf": {
|
||||
"@type": "Organization",
|
||||
"name": "Noisebridge",
|
||||
"url": "https://www.noisebridge.net"
|
||||
},
|
||||
"affiliation": {
|
||||
"@type": "Organization",
|
||||
"name": "Noisebridge",
|
||||
"url": "https://www.noisebridge.net"
|
||||
},
|
||||
"sameAs": [
|
||||
"https://github.com/jetpham",
|
||||
"https://x.com/exmistsoftware",
|
||||
"https://bsky.app/profile/extremist.software",
|
||||
"https://git.extremist.software"
|
||||
]
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body style="background:#000">
|
||||
<canvas id="canvas" class="fixed top-0 left-0 -z-10 h-screen w-screen" aria-hidden="true"></canvas>
|
||||
<body style="background: #000">
|
||||
<a class="skip-link" href="#outlet">Skip to content</a>
|
||||
<canvas
|
||||
id="canvas"
|
||||
class="fixed top-0 left-0 -z-10 h-screen w-screen"
|
||||
aria-hidden="true"
|
||||
></canvas>
|
||||
<nav aria-label="Main navigation" class="flex justify-center px-4">
|
||||
<div class="relative px-[2ch] py-[1ch] mt-[2ch] w-full max-w-[66.666667%] min-w-fit">
|
||||
<div
|
||||
class="relative mt-[2ch] w-full max-w-[66.666667%] min-w-fit px-[2ch] py-[1ch]"
|
||||
>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0"
|
||||
style="background-color: rgba(0, 0, 0, 0.75); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);"
|
||||
style="
|
||||
background-color: rgba(0, 0, 0, 0.75);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
<div
|
||||
class="absolute inset-0 border-2 border-white"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
<div class="absolute inset-0 border-2 border-white" aria-hidden="true"></div>
|
||||
<div class="relative z-10 flex justify-center gap-[2ch]">
|
||||
<a href="/">[HOME]</a>
|
||||
<a href="/qa">[Q&A]</a>
|
||||
<a href="/" data-nav-link>[HOME]</a>
|
||||
<a href="/qa" data-nav-link>[Q&A]</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div id="outlet"></div>
|
||||
<main id="outlet" tabindex="-1"></main>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
62
src/lib/qa.ts
Normal file
62
src/lib/qa.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
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,
|
||||
);
|
||||
}
|
||||
215
src/pages/qa.ts
215
src/pages/qa.ts
|
|
@ -1,4 +1,11 @@
|
|||
import { getQuestions, submitQuestion } from "~/lib/api";
|
||||
import { getQuestions, submitQuestion, type Question } from "~/lib/api";
|
||||
import {
|
||||
clearQuestionDraft,
|
||||
formatExactTimestamp,
|
||||
formatRelativeTimestamp,
|
||||
readQuestionDraft,
|
||||
writeQuestionDraft,
|
||||
} from "~/lib/qa";
|
||||
import { frostedBox } from "~/components/frosted-box";
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
|
|
@ -7,81 +14,197 @@ 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 `
|
||||
<span title="${escapeHtml(askedExact)}">Asked ${escapeHtml(asked)}</span>
|
||||
·
|
||||
<span title="${escapeHtml(answeredExact)}">Answered ${escapeHtml(answered)}</span>`;
|
||||
}
|
||||
|
||||
function renderQuestions(list: HTMLElement, questions: Question[]) {
|
||||
if (questions.length === 0) {
|
||||
list.innerHTML = `
|
||||
<fieldset class="border-2 border-white px-[calc(1.5ch-0.5px)] pb-[1ch] pt-0">
|
||||
<legend class="-mx-[0.5ch] px-[0.5ch]" style="color: var(--dark-gray);">No answers yet</legend>
|
||||
<p>No questions have been answered yet.</p>
|
||||
<p class="qa-meta">Ask something above and check back once a reply is posted.</p>
|
||||
</fieldset>`;
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = questions
|
||||
.map(
|
||||
(q) => `
|
||||
<fieldset class="border-2 border-white px-[calc(1.5ch-0.5px)] pb-[1ch] pt-0 mb-[2ch]">
|
||||
<legend class="-mx-[0.5ch] px-[0.5ch]" style="color: var(--dark-gray);">#${String(q.id)}</legend>
|
||||
<p style="color: var(--light-cyan);">${escapeHtml(q.question)}</p>
|
||||
<p class="mt-[1ch]" style="color: var(--light-green);">${escapeHtml(q.answer)}</p>
|
||||
<p class="qa-meta">${formatQuestionDates(q)}</p>
|
||||
</fieldset>`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
export async function qaPage(outlet: HTMLElement) {
|
||||
const draft = readQuestionDraft();
|
||||
|
||||
outlet.innerHTML = `
|
||||
<div class="flex flex-col items-center justify-start px-4">
|
||||
${frostedBox(`
|
||||
<form id="qa-form">
|
||||
<form id="qa-form" novalidate>
|
||||
<fieldset class="border-2 border-white px-[calc(1.5ch-0.5px)] pb-[1ch] pt-[1ch]">
|
||||
<legend class="-mx-[0.5ch] px-[0.5ch] text-white">Ask a Question</legend>
|
||||
<label class="sr-only" for="qa-input">Question</label>
|
||||
<textarea id="qa-input" maxlength="200" rows="3"
|
||||
class="qa-textarea"
|
||||
placeholder="Type your question..."></textarea>
|
||||
<div class="flex justify-between mt-[1ch]">
|
||||
<span id="char-count" style="color: var(--dark-gray);">0/200</span>
|
||||
<button type="submit" class="qa-button">[SUBMIT]</button>
|
||||
aria-describedby="qa-helper qa-validation char-count"
|
||||
placeholder="Type your question...">${escapeHtml(draft)}</textarea>
|
||||
<p id="qa-helper" class="qa-helper">Press Enter to send. Press Shift+Enter for a new line.</p>
|
||||
<div class="mt-[0.5ch] flex justify-between gap-[1ch]">
|
||||
<p id="qa-validation" class="qa-meta" aria-live="polite"></p>
|
||||
<span id="char-count" class="qa-meta">${draft.length}/200</span>
|
||||
</div>
|
||||
<div class="mt-[1ch] flex justify-between items-center gap-[1ch]">
|
||||
<p id="qa-status" class="qa-meta" aria-live="polite"></p>
|
||||
<button id="qa-submit" type="submit" class="qa-button">[SUBMIT]</button>
|
||||
</div>
|
||||
<p id="qa-status" class="mt-[0.5ch]" aria-live="polite"></p>
|
||||
</fieldset>
|
||||
</form>
|
||||
<div id="qa-list" class="mt-[2ch]">Loading...</div>
|
||||
<div id="qa-list" class="mt-[2ch]" aria-live="polite">Loading answered questions...</div>
|
||||
`)}
|
||||
</div>`;
|
||||
|
||||
const form = document.getElementById("qa-form") as HTMLFormElement;
|
||||
const input = document.getElementById("qa-input") as HTMLTextAreaElement;
|
||||
const charCount = document.getElementById("char-count")!;
|
||||
const status = document.getElementById("qa-status")!;
|
||||
const list = document.getElementById("qa-list")!;
|
||||
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 = `
|
||||
<fieldset class="border-2 border-white px-[calc(1.5ch-0.5px)] pb-[1ch] pt-0">
|
||||
<legend class="-mx-[0.5ch] px-[0.5ch]" style="color: var(--light-red);">Load failed</legend>
|
||||
<p>Failed to load answered questions.</p>
|
||||
<p class="qa-meta">
|
||||
<button type="button" id="qa-retry" class="qa-inline-action">Retry loading questions</button>
|
||||
</p>
|
||||
</fieldset>`;
|
||||
list.style.color = "var(--light-red)";
|
||||
|
||||
const retryButton = document.getElementById(
|
||||
"qa-retry",
|
||||
) as HTMLButtonElement | null;
|
||||
retryButton?.addEventListener("click", () => {
|
||||
void loadQuestions();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
input.addEventListener("input", () => {
|
||||
charCount.textContent = `${input.value.length}/200`;
|
||||
hasInteracted = true;
|
||||
writeQuestionDraft(input.value);
|
||||
updateValidation();
|
||||
});
|
||||
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
form.requestSubmit();
|
||||
}
|
||||
});
|
||||
|
||||
form.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
const question = input.value.trim();
|
||||
if (!question) return;
|
||||
if (isSubmitting) return;
|
||||
|
||||
status.textContent = "Submitting...";
|
||||
status.style.color = "var(--light-gray)";
|
||||
const question = input.value.trim();
|
||||
hasInteracted = true;
|
||||
if (!updateValidation() || !question) {
|
||||
setStatus("Write a question before submitting.", "var(--light-red)");
|
||||
input.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting = true;
|
||||
submitButton.disabled = true;
|
||||
setStatus("Submitting...", "var(--light-gray)");
|
||||
|
||||
submitQuestion(question)
|
||||
.then(() => {
|
||||
input.value = "";
|
||||
charCount.textContent = "0/200";
|
||||
status.textContent = "Question submitted! It will appear here once answered.";
|
||||
status.style.color = "var(--light-green)";
|
||||
clearQuestionDraft();
|
||||
hasInteracted = false;
|
||||
updateValidation();
|
||||
setStatus(
|
||||
"Question submitted! It will appear here once answered.",
|
||||
"var(--light-green)",
|
||||
);
|
||||
input.focus();
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
status.textContent = err instanceof Error ? err.message : "Failed to submit question.";
|
||||
status.style.color = "var(--light-red)";
|
||||
setStatus(
|
||||
err instanceof Error ? err.message : "Failed to submit question.",
|
||||
"var(--light-red)",
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
isSubmitting = false;
|
||||
submitButton.disabled = false;
|
||||
});
|
||||
});
|
||||
|
||||
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) => `
|
||||
<fieldset class="border-2 border-white px-[calc(1.5ch-0.5px)] pb-[1ch] pt-0 mb-[2ch]">
|
||||
<legend class="-mx-[0.5ch] px-[0.5ch]" style="color: var(--dark-gray);">#${String(q.id)}</legend>
|
||||
<p style="color: var(--light-cyan);">${escapeHtml(q.question)}</p>
|
||||
<p class="mt-[1ch]" style="color: var(--light-green);">${escapeHtml(q.answer)}</p>
|
||||
<p class="mt-[0.5ch]" style="color: var(--dark-gray);">
|
||||
Asked ${q.created_at} · Answered ${q.answered_at}
|
||||
</p>
|
||||
</fieldset>`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
} catch {
|
||||
list.textContent = "Failed to load questions.";
|
||||
list.style.color = "var(--light-red)";
|
||||
list.style.textAlign = "center";
|
||||
}
|
||||
updateValidation();
|
||||
await loadQuestions();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,12 +27,26 @@ export function route(path: string, handler: PageHandler) {
|
|||
|
||||
export function navigate(path: string) {
|
||||
history.pushState(null, "", path);
|
||||
void render();
|
||||
window.scrollTo({ top: 0, behavior: "auto" });
|
||||
void render(true);
|
||||
}
|
||||
|
||||
async function render() {
|
||||
function updateNavState(path: string) {
|
||||
const navLinks =
|
||||
document.querySelectorAll<HTMLAnchorElement>("[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) {
|
||||
const path = location.pathname;
|
||||
const outlet = document.getElementById("outlet")!;
|
||||
updateNavState(path);
|
||||
|
||||
for (const r of routes) {
|
||||
const match = path.match(r.pattern);
|
||||
|
|
@ -43,6 +57,7 @@ async function render() {
|
|||
});
|
||||
outlet.innerHTML = "";
|
||||
await r.handler(outlet, params);
|
||||
if (focusOutlet) outlet.focus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -51,14 +66,20 @@ async function render() {
|
|||
if (notFoundHandler) {
|
||||
await notFoundHandler(outlet, {});
|
||||
}
|
||||
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.hasAttribute("download")) {
|
||||
if (
|
||||
anchor?.origin === location.origin &&
|
||||
!anchor.hash &&
|
||||
!anchor.hasAttribute("download") &&
|
||||
!anchor.hasAttribute("target")
|
||||
) {
|
||||
e.preventDefault();
|
||||
navigate(anchor.pathname);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,144 +9,232 @@
|
|||
}
|
||||
|
||||
: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 */
|
||||
|
||||
/* 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) {
|
||||
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;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
/* 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);
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
/* Apply CGA theme to body */
|
||||
body {
|
||||
height: 100%;
|
||||
background-color: var(--black);
|
||||
color: var(--white);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.qa-button {
|
||||
border: none;
|
||||
padding: 0.25ch 1ch;
|
||||
color: var(--yellow);
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
#outlet {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.qa-button:hover {
|
||||
background-color: var(--yellow);
|
||||
color: var(--black);
|
||||
}
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* Project markdown content */
|
||||
.project-content h2 {
|
||||
color: var(--light-cyan);
|
||||
margin-top: 2ch;
|
||||
margin-bottom: 1ch;
|
||||
}
|
||||
/* Link styles - blue without underline */
|
||||
a {
|
||||
color: var(--light-blue);
|
||||
text-decoration: none;
|
||||
text-underline-offset: 0.2ch;
|
||||
}
|
||||
|
||||
.project-content h3 {
|
||||
color: var(--light-green);
|
||||
margin-top: 1.5ch;
|
||||
margin-bottom: 0.5ch;
|
||||
}
|
||||
a:hover,
|
||||
a:focus-visible,
|
||||
a[aria-current="page"] {
|
||||
background-color: var(--light-blue);
|
||||
color: var(--black);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.project-content p {
|
||||
margin-bottom: 1ch;
|
||||
}
|
||||
a[href^="http://"]::after,
|
||||
a[href^="https://"]::after {
|
||||
content: " [EXT]";
|
||||
color: var(--dark-gray);
|
||||
}
|
||||
|
||||
.project-content ul,
|
||||
.project-content ol {
|
||||
margin-left: 2ch;
|
||||
margin-bottom: 1ch;
|
||||
}
|
||||
.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 code {
|
||||
color: var(--yellow);
|
||||
}
|
||||
.skip-link:focus {
|
||||
transform: translateY(1rem);
|
||||
}
|
||||
|
||||
.project-content pre {
|
||||
border: 2px solid var(--dark-gray);
|
||||
padding: 1ch;
|
||||
overflow-x: auto;
|
||||
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 a {
|
||||
color: var(--light-blue);
|
||||
}
|
||||
.qa-textarea::placeholder {
|
||||
color: var(--dark-gray);
|
||||
}
|
||||
|
||||
.qa-button {
|
||||
border: none;
|
||||
padding: 0.25ch 1ch;
|
||||
color: var(--yellow);
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue