feat: add fixes and more support for wierd api bugs
This commit is contained in:
parent
1149597139
commit
691394445a
6 changed files with 686 additions and 242 deletions
|
|
@ -5,6 +5,7 @@ use axum::http::{HeaderMap, StatusCode};
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::email;
|
use crate::email;
|
||||||
use crate::serve::AppState;
|
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(
|
fn extract_qa_reply_from_message(
|
||||||
recipients: &[Recipient],
|
recipients: &[Recipient],
|
||||||
expected_domain: &str,
|
expected_domain: &str,
|
||||||
|
|
@ -290,14 +377,27 @@ fn extract_qa_reply_from_message(
|
||||||
pub async fn webhook(
|
pub async fn webhook(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Json(payload): Json<MtaHookPayload>,
|
body: String,
|
||||||
) -> Result<Json<MtaHookResponse>, (StatusCode, String)> {
|
) -> Result<Json<MtaHookResponse>, (StatusCode, String)> {
|
||||||
if !webhook_secret_matches(&headers, &state.webhook_secret) {
|
if !webhook_secret_matches(&headers, &state.webhook_secret) {
|
||||||
eprintln!("Rejected webhook: invalid secret");
|
eprintln!("Rejected webhook: invalid secret");
|
||||||
return Err((StatusCode::UNAUTHORIZED, "invalid secret".to_string()));
|
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
|
let db = state
|
||||||
.db
|
.db
|
||||||
.lock()
|
.lock()
|
||||||
|
|
@ -313,15 +413,16 @@ pub async fn webhook(
|
||||||
return Ok(Json(MtaHookResponse { action: "discard" }));
|
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" }))
|
Ok(Json(MtaHookResponse { action: "accept" }))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use axum::http::HeaderMap;
|
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]
|
#[test]
|
||||||
fn extracts_reply_from_current_stalwart_payload() {
|
fn extracts_reply_from_current_stalwart_payload() {
|
||||||
|
|
@ -391,4 +492,27 @@ mod tests {
|
||||||
|
|
||||||
assert!(webhook_secret_matches(&headers, "topsecret"));
|
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()))
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
63
index.html
63
index.html
|
|
@ -6,25 +6,46 @@
|
||||||
<meta name="theme-color" content="#000000" />
|
<meta name="theme-color" content="#000000" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Jet Pham" />
|
<meta name="apple-mobile-web-app-title" content="Jet Pham" />
|
||||||
<title>Jet Pham - Software Extremist</title>
|
<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="canonical" href="https://jetpham.com/" />
|
||||||
<link rel="manifest" href="/manifest.json" />
|
<link rel="manifest" href="/manifest.json" />
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
<link rel="apple-touch-icon" href="/apple-icon.png" />
|
<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: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:type" content="website" />
|
||||||
<meta property="og:url" content="https://jetpham.com/" />
|
<meta property="og:url" content="https://jetpham.com/" />
|
||||||
<meta property="og:site_name" content="Jet Pham" />
|
<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:width" content="512" />
|
||||||
<meta property="og:image:height" content="512" />
|
<meta property="og:image:height" content="512" />
|
||||||
<meta property="og:image:alt" content="Jet Pham" />
|
<meta property="og:image:alt" content="Jet Pham" />
|
||||||
<meta name="twitter:card" content="summary" />
|
<meta name="twitter:card" content="summary" />
|
||||||
<meta name="twitter:title" content="Jet Pham - Software Extremist" />
|
<meta name="twitter:title" content="Jet Pham - Software Extremist" />
|
||||||
<meta name="twitter:description" content="Jet Pham's personal website. Software extremist." />
|
<meta
|
||||||
<meta name="twitter:image" content="https://jetpham.com/web-app-manifest-512x512.png" />
|
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">
|
<script type="application/ld+json">
|
||||||
{
|
{
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
|
|
@ -78,23 +99,37 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body style="background:#000">
|
<body style="background: #000">
|
||||||
<canvas id="canvas" class="fixed top-0 left-0 -z-10 h-screen w-screen" aria-hidden="true"></canvas>
|
<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">
|
<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
|
<div
|
||||||
class="pointer-events-none absolute inset-0"
|
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"
|
aria-hidden="true"
|
||||||
></div>
|
></div>
|
||||||
<div class="absolute inset-0 border-2 border-white" aria-hidden="true"></div>
|
|
||||||
<div class="relative z-10 flex justify-center gap-[2ch]">
|
<div class="relative z-10 flex justify-center gap-[2ch]">
|
||||||
<a href="/">[HOME]</a>
|
<a href="/" data-nav-link>[HOME]</a>
|
||||||
<a href="/qa">[Q&A]</a>
|
<a href="/qa" data-nav-link>[Q&A]</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<div id="outlet"></div>
|
<main id="outlet" tabindex="-1"></main>
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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,
|
||||||
|
);
|
||||||
|
}
|
||||||
210
src/pages/qa.ts
210
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";
|
import { frostedBox } from "~/components/frosted-box";
|
||||||
|
|
||||||
function escapeHtml(str: string): string {
|
function escapeHtml(str: string): string {
|
||||||
|
|
@ -7,35 +14,148 @@ function escapeHtml(str: string): string {
|
||||||
return div.innerHTML;
|
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) {
|
export async function qaPage(outlet: HTMLElement) {
|
||||||
|
const draft = readQuestionDraft();
|
||||||
|
|
||||||
outlet.innerHTML = `
|
outlet.innerHTML = `
|
||||||
<div class="flex flex-col items-center justify-start px-4">
|
<div class="flex flex-col items-center justify-start px-4">
|
||||||
${frostedBox(`
|
${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]">
|
<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>
|
<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"
|
<textarea id="qa-input" maxlength="200" rows="3"
|
||||||
class="qa-textarea"
|
class="qa-textarea"
|
||||||
placeholder="Type your question..."></textarea>
|
aria-describedby="qa-helper qa-validation char-count"
|
||||||
<div class="flex justify-between mt-[1ch]">
|
placeholder="Type your question...">${escapeHtml(draft)}</textarea>
|
||||||
<span id="char-count" style="color: var(--dark-gray);">0/200</span>
|
<p id="qa-helper" class="qa-helper">Press Enter to send. Press Shift+Enter for a new line.</p>
|
||||||
<button type="submit" class="qa-button">[SUBMIT]</button>
|
<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>
|
</div>
|
||||||
<p id="qa-status" class="mt-[0.5ch]" aria-live="polite"></p>
|
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</form>
|
</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>`;
|
</div>`;
|
||||||
|
|
||||||
const form = document.getElementById("qa-form") as HTMLFormElement;
|
const form = document.getElementById("qa-form") as HTMLFormElement;
|
||||||
const input = document.getElementById("qa-input") as HTMLTextAreaElement;
|
const input = document.getElementById("qa-input") as HTMLTextAreaElement;
|
||||||
const charCount = document.getElementById("char-count")!;
|
const submitButton = document.getElementById(
|
||||||
const status = document.getElementById("qa-status")!;
|
"qa-submit",
|
||||||
const list = document.getElementById("qa-list")!;
|
) 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", () => {
|
input.addEventListener("input", () => {
|
||||||
charCount.textContent = `${input.value.length}/200`;
|
hasInteracted = true;
|
||||||
|
writeQuestionDraft(input.value);
|
||||||
|
updateValidation();
|
||||||
});
|
});
|
||||||
|
|
||||||
input.addEventListener("keydown", (e) => {
|
input.addEventListener("keydown", (e) => {
|
||||||
|
|
@ -47,50 +167,44 @@ export async function qaPage(outlet: HTMLElement) {
|
||||||
|
|
||||||
form.addEventListener("submit", (e) => {
|
form.addEventListener("submit", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const question = input.value.trim();
|
if (isSubmitting) return;
|
||||||
if (!question) return;
|
|
||||||
|
|
||||||
status.textContent = "Submitting...";
|
const question = input.value.trim();
|
||||||
status.style.color = "var(--light-gray)";
|
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)
|
submitQuestion(question)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
input.value = "";
|
input.value = "";
|
||||||
charCount.textContent = "0/200";
|
clearQuestionDraft();
|
||||||
status.textContent =
|
hasInteracted = false;
|
||||||
"Question submitted! It will appear here once answered.";
|
updateValidation();
|
||||||
status.style.color = "var(--light-green)";
|
setStatus(
|
||||||
|
"Question submitted! It will appear here once answered.",
|
||||||
|
"var(--light-green)",
|
||||||
|
);
|
||||||
|
input.focus();
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
status.textContent =
|
setStatus(
|
||||||
err instanceof Error ? err.message : "Failed to submit question.";
|
err instanceof Error ? err.message : "Failed to submit question.",
|
||||||
status.style.color = "var(--light-red)";
|
"var(--light-red)",
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
isSubmitting = false;
|
||||||
|
submitButton.disabled = false;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
updateValidation();
|
||||||
const questions = await getQuestions();
|
await loadQuestions();
|
||||||
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";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,12 +27,26 @@ export function route(path: string, handler: PageHandler) {
|
||||||
|
|
||||||
export function navigate(path: string) {
|
export function navigate(path: string) {
|
||||||
history.pushState(null, "", path);
|
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 path = location.pathname;
|
||||||
const outlet = document.getElementById("outlet")!;
|
const outlet = document.getElementById("outlet")!;
|
||||||
|
updateNavState(path);
|
||||||
|
|
||||||
for (const r of routes) {
|
for (const r of routes) {
|
||||||
const match = path.match(r.pattern);
|
const match = path.match(r.pattern);
|
||||||
|
|
@ -43,6 +57,7 @@ async function render() {
|
||||||
});
|
});
|
||||||
outlet.innerHTML = "";
|
outlet.innerHTML = "";
|
||||||
await r.handler(outlet, params);
|
await r.handler(outlet, params);
|
||||||
|
if (focusOutlet) outlet.focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -51,14 +66,20 @@ async function render() {
|
||||||
if (notFoundHandler) {
|
if (notFoundHandler) {
|
||||||
await notFoundHandler(outlet, {});
|
await notFoundHandler(outlet, {});
|
||||||
}
|
}
|
||||||
|
if (focusOutlet) outlet.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initRouter() {
|
export function initRouter() {
|
||||||
window.addEventListener("popstate", () => void render());
|
window.addEventListener("popstate", () => void render(true));
|
||||||
|
|
||||||
document.addEventListener("click", (e) => {
|
document.addEventListener("click", (e) => {
|
||||||
const anchor = (e.target as HTMLElement).closest("a");
|
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();
|
e.preventDefault();
|
||||||
navigate(anchor.pathname);
|
navigate(anchor.pathname);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,32 +9,39 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--font-sans: "IBM VGA", ui-monospace, "SF Mono", "Monaco", "Inconsolata", "Roboto Mono", "Source Code Pro", monospace;
|
--font-sans:
|
||||||
--font-mono: "IBM VGA", ui-monospace, "SF Mono", "Monaco", "Inconsolata", "Roboto Mono", "Source Code Pro", monospace;
|
"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 */
|
/* 16-color palette */
|
||||||
--black: #000000;
|
--black: #000000;
|
||||||
--blue: #0000AA;
|
--blue: #0000aa;
|
||||||
--green: #00AA00;
|
--green: #00aa00;
|
||||||
--cyan: #00AAAA;
|
--cyan: #00aaaa;
|
||||||
--red: #AA0000;
|
--red: #aa0000;
|
||||||
--magenta: #AA00AA;
|
--magenta: #aa00aa;
|
||||||
--brown: #AA5500;
|
--brown: #aa5500;
|
||||||
--light-gray: #AAAAAA;
|
--light-gray: #aaaaaa;
|
||||||
--dark-gray: #555555;
|
--dark-gray: #555555;
|
||||||
--light-blue: #5555FF;
|
--light-blue: #5555ff;
|
||||||
--light-green: #55FF55;
|
--light-green: #55ff55;
|
||||||
--light-cyan: #55FFFF;
|
--light-cyan: #55ffff;
|
||||||
--light-red: #FF5555;
|
--light-red: #ff5555;
|
||||||
--light-magenta: #FF55FF;
|
--light-magenta: #ff55ff;
|
||||||
--yellow: #FFFF55;
|
--yellow: #ffff55;
|
||||||
--white: #FFFFFF;
|
--white: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Global BBS-style 80-character width constraint - responsive */
|
/* Global BBS-style 80-character width constraint - responsive */
|
||||||
html {
|
html {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: min(80ch, 100vw); /* 80 characters wide on desktop, full width on mobile */
|
width: min(
|
||||||
|
80ch,
|
||||||
|
100vw
|
||||||
|
); /* 80 characters wide on desktop, full width on mobile */
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
font-family: "IBM VGA", monospace;
|
font-family: "IBM VGA", monospace;
|
||||||
|
|
@ -42,50 +49,79 @@
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Desktop font size */
|
/* Desktop font size */
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
html {
|
html {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Apply CGA theme to body */
|
/* Apply CGA theme to body */
|
||||||
body {
|
body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background-color: var(--black);
|
background-color: var(--black);
|
||||||
color: var(--white);
|
color: var(--white);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Global focus ring styles for all tabbable elements */
|
#outlet {
|
||||||
button:focus,
|
display: block;
|
||||||
a:focus,
|
}
|
||||||
input:focus,
|
|
||||||
textarea:focus,
|
/* Global focus ring styles for all tabbable elements */
|
||||||
select:focus,
|
button:focus,
|
||||||
[tabindex]:focus,
|
a:focus,
|
||||||
[contenteditable]:focus {
|
input:focus,
|
||||||
|
textarea:focus,
|
||||||
|
select:focus,
|
||||||
|
[tabindex]:focus,
|
||||||
|
[contenteditable]:focus {
|
||||||
outline: 2px solid white;
|
outline: 2px solid white;
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
|
||||||
|
|
||||||
/* Link styles - blue without underline */
|
|
||||||
a {
|
|
||||||
color: var(--light-blue);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
a:hover {
|
/* Link styles - blue without underline */
|
||||||
|
a {
|
||||||
|
color: var(--light-blue);
|
||||||
|
text-decoration: none;
|
||||||
|
text-underline-offset: 0.2ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover,
|
||||||
|
a:focus-visible,
|
||||||
|
a[aria-current="page"] {
|
||||||
background-color: var(--light-blue);
|
background-color: var(--light-blue);
|
||||||
color: var(--black);
|
color: var(--black);
|
||||||
}
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
/* Form inputs */
|
a[href^="http://"]::after,
|
||||||
.qa-textarea {
|
a[href^="https://"]::after {
|
||||||
|
content: " [EXT]";
|
||||||
|
color: var(--dark-gray);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-link {
|
||||||
|
position: absolute;
|
||||||
|
left: 1rem;
|
||||||
|
top: 0;
|
||||||
|
transform: translateY(-150%);
|
||||||
|
padding: 0.5ch 1ch;
|
||||||
|
background: var(--yellow);
|
||||||
|
color: var(--black);
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-link:focus {
|
||||||
|
transform: translateY(1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form inputs */
|
||||||
|
.qa-textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background-color: var(--black);
|
background-color: var(--black);
|
||||||
border: 2px solid var(--white);
|
border: 2px solid var(--white);
|
||||||
|
|
@ -95,9 +131,13 @@
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
line-height: inherit;
|
line-height: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qa-button {
|
.qa-textarea::placeholder {
|
||||||
|
color: var(--dark-gray);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qa-button {
|
||||||
border: none;
|
border: none;
|
||||||
padding: 0.25ch 1ch;
|
padding: 0.25ch 1ch;
|
||||||
color: var(--yellow);
|
color: var(--yellow);
|
||||||
|
|
@ -105,48 +145,96 @@
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qa-button:hover {
|
.qa-button:hover {
|
||||||
background-color: var(--yellow);
|
background-color: var(--yellow);
|
||||||
color: var(--black);
|
color: var(--black);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Project markdown content */
|
.qa-button:disabled {
|
||||||
.project-content h2 {
|
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);
|
color: var(--light-cyan);
|
||||||
margin-top: 2ch;
|
margin-top: 2ch;
|
||||||
margin-bottom: 1ch;
|
margin-bottom: 1ch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-content h3 {
|
.project-content h3 {
|
||||||
color: var(--light-green);
|
color: var(--light-green);
|
||||||
margin-top: 1.5ch;
|
margin-top: 1.5ch;
|
||||||
margin-bottom: 0.5ch;
|
margin-bottom: 0.5ch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-content p {
|
.project-content p {
|
||||||
margin-bottom: 1ch;
|
margin-bottom: 1ch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-content ul,
|
.project-content ul,
|
||||||
.project-content ol {
|
.project-content ol {
|
||||||
margin-left: 2ch;
|
margin-left: 2ch;
|
||||||
margin-bottom: 1ch;
|
margin-bottom: 1ch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-content code {
|
.project-content code {
|
||||||
color: var(--yellow);
|
color: var(--yellow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-content pre {
|
.project-content pre {
|
||||||
border: 2px solid var(--dark-gray);
|
border: 2px solid var(--dark-gray);
|
||||||
padding: 1ch;
|
padding: 1ch;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
margin-bottom: 1ch;
|
margin-bottom: 1ch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-content a {
|
.project-content a {
|
||||||
color: var(--light-blue);
|
color: var(--light-blue);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue