fix: extensive ui improvements

This commit is contained in:
Jet 2026-03-26 01:32:59 -07:00
parent 691394445a
commit 6ba64d29a9
No known key found for this signature in database
17 changed files with 684 additions and 142 deletions

View file

@ -19,6 +19,12 @@ pub struct Question {
answered_at: String, answered_at: String,
} }
#[derive(Serialize)]
pub struct QuestionStats {
asked: i64,
answered: i64,
}
pub async fn get_questions( pub async fn get_questions(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
) -> Result<Json<Vec<Question>>, StatusCode> { ) -> Result<Json<Vec<Question>>, StatusCode> {
@ -51,6 +57,29 @@ pub async fn get_questions(
Ok(Json(questions)) Ok(Json(questions))
} }
pub async fn get_question_stats(
State(state): State<Arc<AppState>>,
) -> Result<Json<QuestionStats>, StatusCode> {
let db = state
.db
.lock()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let asked: i64 = db
.query_row("SELECT COUNT(*) FROM questions", [], |row| row.get(0))
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let answered: i64 = db
.query_row(
"SELECT COUNT(*) FROM questions WHERE answer IS NOT NULL",
[],
|row| row.get(0),
)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(QuestionStats { asked, answered }))
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct SubmitQuestion { pub struct SubmitQuestion {
question: String, question: String,
@ -191,10 +220,12 @@ pub struct MtaHookResponse {
} }
fn webhook_secret_matches(headers: &HeaderMap, expected_secret: &str) -> bool { fn webhook_secret_matches(headers: &HeaderMap, expected_secret: &str) -> bool {
let expected_secret = expected_secret.trim();
let header_secret = headers let header_secret = headers
.get("X-Webhook-Secret") .get("X-Webhook-Secret")
.and_then(|v| v.to_str().ok()) .and_then(|v| v.to_str().ok())
.unwrap_or(""); .unwrap_or("")
.trim();
if header_secret == expected_secret { if header_secret == expected_secret {
return true; return true;
} }
@ -224,7 +255,29 @@ fn webhook_secret_matches(headers: &HeaderMap, expected_secret: &str) -> bool {
None => return false, None => return false,
}; };
password == expected_secret password.trim() == expected_secret
}
fn webhook_secret_debug(headers: &HeaderMap) -> String {
let header_secret = headers
.get("X-Webhook-Secret")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let auth = headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let decoded = auth
.strip_prefix("Basic ")
.and_then(|encoded| base64::engine::general_purpose::STANDARD.decode(encoded).ok())
.and_then(|bytes| String::from_utf8(bytes).ok())
.unwrap_or_default();
format!(
"x-webhook-secret={header_secret:?}; authorization={auth:?}; basic-decoded={decoded:?}"
)
} }
fn extract_qa_reply(payload: &MtaHookPayload, expected_domain: &str) -> Option<(i64, String)> { fn extract_qa_reply(payload: &MtaHookPayload, expected_domain: &str) -> Option<(i64, String)> {
@ -304,6 +357,21 @@ fn string_at_paths(value: &Value, paths: &[&[&str]]) -> Option<String> {
}) })
} }
fn subject_from_headers_value(value: Option<&Value>) -> Option<String> {
let headers = value?.as_array()?;
headers.iter().find_map(|header| {
let parts = header.as_array()?;
let name = parts.first()?.as_str()?.trim();
if !name.eq_ignore_ascii_case("Subject") {
return None;
}
parts
.get(1)?
.as_str()
.map(|s| s.trim().to_string())
})
}
fn extract_qa_reply_from_value(payload: &Value, expected_domain: &str) -> Option<(i64, String)> { 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) { if let Some(messages) = payload.get("messages").and_then(Value::as_array) {
for message in messages { for message in messages {
@ -318,6 +386,7 @@ fn extract_qa_reply_from_value(payload: &Value, expected_domain: &str) -> Option
&["headers", "subject"], &["headers", "subject"],
], ],
) )
.or_else(|| subject_from_headers_value(value_at_path(message, &["message", "headers"])))
.as_deref(), .as_deref(),
&string_at_paths(message, &[&["message", "contents"], &["contents"], &["raw_message"]]) &string_at_paths(message, &[&["message", "contents"], &["contents"], &["raw_message"]])
.unwrap_or_default(), .unwrap_or_default(),
@ -338,6 +407,7 @@ fn extract_qa_reply_from_value(payload: &Value, expected_domain: &str) -> Option
&["headers", "subject"], &["headers", "subject"],
], ],
) )
.or_else(|| subject_from_headers_value(value_at_path(payload, &["message", "headers"])))
.as_deref(), .as_deref(),
&string_at_paths(payload, &[&["message", "contents"], &["contents"], &["raw_message"]]) &string_at_paths(payload, &[&["message", "contents"], &["contents"], &["raw_message"]])
.unwrap_or_default(), .unwrap_or_default(),
@ -380,7 +450,11 @@ pub async fn webhook(
body: String, 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; expected_len={}; {}",
state.webhook_secret.len(),
webhook_secret_debug(&headers)
);
return Err((StatusCode::UNAUTHORIZED, "invalid secret".to_string())); return Err((StatusCode::UNAUTHORIZED, "invalid secret".to_string()));
} }
@ -515,4 +589,28 @@ mod tests {
Some((9, "Answer body".to_string())) Some((9, "Answer body".to_string()))
); );
} }
#[test]
fn extracts_reply_from_value_with_header_pairs() {
let payload: Value = serde_json::from_str(
r#"{
"envelope": {
"to": [{"address":"qa@extremist.software"}]
},
"message": {
"headers": [
["From", " jet@extremist.software\r\n"],
["Subject", " Re: 11 - hi\r\n"]
],
"contents": "Answer from header pairs"
}
}"#,
)
.unwrap();
assert_eq!(
extract_qa_reply_from_value(&payload, "extremist.software"),
Some((11, "Answer from header pairs".to_string()))
);
}
} }

View file

@ -52,6 +52,7 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
"/api/questions", "/api/questions",
get(handlers::get_questions).post(handlers::post_question), get(handlers::get_questions).post(handlers::post_question),
) )
.route("/api/questions/stats", get(handlers::get_question_stats))
.route("/api/webhook", post(handlers::webhook)) .route("/api/webhook", post(handlers::webhook))
.layer(CorsLayer::permissive()) .layer(CorsLayer::permissive())
.with_state(state); .with_state(state);

View file

@ -106,30 +106,20 @@
class="fixed top-0 left-0 -z-10 h-screen w-screen" class="fixed top-0 left-0 -z-10 h-screen w-screen"
aria-hidden="true" aria-hidden="true"
></canvas> ></canvas>
<nav aria-label="Main navigation" class="flex justify-center px-4"> <div class="page-frame">
<div <nav aria-label="Main navigation" class="site-region">
class="relative mt-[2ch] w-full max-w-[66.666667%] min-w-fit px-[2ch] py-[1ch]" <div class="site-shell site-panel px-[2ch] py-[1ch]">
> <div class="flex justify-center gap-[2ch]">
<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);
"
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="/" data-nav-link>[HOME]</a> <a href="/" data-nav-link>[HOME]</a>
<a href="/qa" data-nav-link>[Q&amp;A]</a> <a href="/qa" data-nav-link>[Q&amp;A]</a>
</div> </div>
</div> </div>
</nav> </nav>
<main id="outlet" tabindex="-1"></main> <main id="outlet" class="site-region" tabindex="-1"></main>
<footer class="site-region site-footer">
<div id="site-footer" class="site-shell"></div>
</footer>
</div>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </html>

0
public/pgp.txt Normal file
View file

2
public/ssh.txt Normal file
View file

@ -0,0 +1,2 @@
# Jet Pham SSH fingerprints
ssh-ed25519 SHA256:Ziw7a2bUA1ew4AFQLB8rk9G3l9I4/eRClf9OJMLMLUA

View file

@ -1,13 +1,13 @@
export function frostedBox(content: string, extraClasses?: string): string { export function frostedBox(content: string, extraClasses?: string): string {
return ` return `
<div class="relative px-[2ch] py-[2ch] my-[2ch] w-full max-w-[66.666667%] min-w-fit ${extraClasses ?? ""}"> <div class="site-shell relative my-[2ch] overflow-hidden px-[2ch] py-[2ch] ${extraClasses ?? ""}">
<div <div
class="pointer-events-none absolute inset-0 h-[200%]" class="pointer-events-none absolute inset-0 h-[200%]"
style="background-color: rgba(0, 0, 0, 0.75); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); mask-image: linear-gradient(to bottom, black 0% 50%, transparent 50% 100%); -webkit-mask-image: linear-gradient(to bottom, black 0% 50%, transparent 50% 100%);" style="background-color: rgba(0, 0, 0, 0.75); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); mask-image: linear-gradient(to bottom, black 0% 50%, transparent 50% 100%); -webkit-mask-image: linear-gradient(to bottom, black 0% 50%, transparent 50% 100%);"
aria-hidden="true" aria-hidden="true"
></div> ></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"> <div class="relative z-10 h-full min-h-0">
${content} ${content}
</div> </div>
</div>`; </div>`;

2
src/global.d.ts vendored
View file

@ -1,5 +1,7 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
declare const __COMMIT_SHA__: string;
declare module "*.txt?raw" { declare module "*.txt?raw" {
const content: string; const content: string;
export default content; export default content;

View file

@ -6,12 +6,118 @@ export interface Question {
answered_at: string; answered_at: string;
} }
export interface QuestionStats {
asked: number;
answered: number;
}
const DEV_QUESTIONS: Question[] = [
{
id: 1,
question: "What is a fact about octopuses?",
answer: "An octopus has three hearts and blue blood.",
created_at: "2026-03-23T18:10:00.000Z",
answered_at: "2026-03-23T19:00:00.000Z",
},
{
id: 2,
question: "What is a fact about axolotls?",
answer:
"An axolotl can regrow limbs, parts of its heart, and even parts of its brain.",
created_at: "2026-03-24T02:15:00.000Z",
answered_at: "2026-03-24T05:45:00.000Z",
},
{
id: 3,
question: "What is a fact about crows?",
answer: "Crows can recognize human faces and remember them for years.",
created_at: "2026-03-25T08:30:00.000Z",
answered_at: "2026-03-25T09:05:00.000Z",
},
{
id: 4,
question: "What is a fact about wombats?",
answer: "Wombats produce cube-shaped poop.",
created_at: "2026-03-25T11:10:00.000Z",
answered_at: "2026-03-25T11:40:00.000Z",
},
{
id: 5,
question: "What is a fact about mantis shrimp?",
answer:
"A mantis shrimp can punch so fast it creates tiny cavitation bubbles in water.",
created_at: "2026-03-25T13:00:00.000Z",
answered_at: "2026-03-25T13:18:00.000Z",
},
{
id: 6,
question: "What is a fact about sloths?",
answer:
"Some sloths can hold their breath longer than dolphins by slowing their heart rate.",
created_at: "2026-03-25T14:25:00.000Z",
answered_at: "2026-03-25T15:00:00.000Z",
},
{
id: 7,
question: "What is a fact about owls?",
answer:
"An owl cannot rotate its eyes, so it turns its whole head instead.",
created_at: "2026-03-25T16:05:00.000Z",
answered_at: "2026-03-25T16:21:00.000Z",
},
{
id: 8,
question: "What is a fact about capybaras?",
answer:
"Capybaras are the largest rodents in the world and are excellent swimmers.",
created_at: "2026-03-25T18:45:00.000Z",
answered_at: "2026-03-25T19:07:00.000Z",
},
{
id: 9,
question: "What is a fact about penguins?",
answer: "Penguins have solid bones, which help them dive instead of float.",
created_at: "2026-03-25T21:20:00.000Z",
answered_at: "2026-03-25T21:55:00.000Z",
},
{
id: 10,
question: "What is a fact about bats?",
answer: "Bats are the only mammals capable of sustained powered flight.",
created_at: "2026-03-26T00:10:00.000Z",
answered_at: "2026-03-26T00:32:00.000Z",
},
];
const DEV_QUESTION_STATS: QuestionStats = {
asked: 16,
answered: DEV_QUESTIONS.length,
};
export async function getQuestions(): Promise<Question[]> { export async function getQuestions(): Promise<Question[]> {
if (import.meta.env.DEV) return DEV_QUESTIONS;
const res = await fetch("/api/questions"); const res = await fetch("/api/questions");
if (!res.ok) throw new Error("Failed to fetch questions"); if (!res.ok) throw new Error("Failed to fetch questions");
return res.json() as Promise<Question[]>; return res.json() as Promise<Question[]>;
} }
export async function getQuestionStats(): Promise<QuestionStats> {
if (import.meta.env.DEV) return DEV_QUESTION_STATS;
try {
const res = await fetch("/api/questions/stats");
if (!res.ok) throw new Error("Failed to fetch question stats");
return res.json() as Promise<QuestionStats>;
} catch {
const questions = await getQuestions();
return {
asked: questions.length,
answered: questions.length,
};
}
}
export async function submitQuestion(question: string): Promise<void> { export async function submitQuestion(question: string): Promise<void> {
const res = await fetch("/api/questions", { const res = await fetch("/api/questions", {
method: "POST", method: "POST",
@ -19,7 +125,8 @@ export async function submitQuestion(question: string): Promise<void> {
body: JSON.stringify({ question }), body: JSON.stringify({ question }),
}); });
if (!res.ok) { if (!res.ok) {
if (res.status === 429) throw new Error("Too many questions. Please try again later."); if (res.status === 429)
throw new Error("Too many questions. Please try again later.");
throw new Error("Failed to submit question"); throw new Error("Failed to submit question");
} }
} }

View file

@ -1,4 +1,5 @@
const DRAFT_KEY = "qa-draft"; const DRAFT_KEY = "qa-draft";
const LAST_PLACEHOLDER_KEY = "qa-last-placeholder";
const relativeTime = new Intl.RelativeTimeFormat(undefined, { const relativeTime = new Intl.RelativeTimeFormat(undefined, {
numeric: "auto", numeric: "auto",
}); });
@ -32,21 +33,19 @@ export function clearQuestionDraft() {
} }
} }
export function formatExactTimestamp(dateString: string): string { export function formatDateOnly(dateString: string): string {
const date = getValidDate(dateString); const date = getValidDate(dateString);
if (!date) return dateString; if (!date) return dateString;
return new Intl.DateTimeFormat(undefined, { return date.toISOString().slice(0, 10);
dateStyle: "medium",
timeStyle: "short",
}).format(date);
} }
export function formatRelativeTimestamp(dateString: string): string { export function formatFriendlyDate(dateString: string): string {
const date = getValidDate(dateString); const date = getValidDate(dateString);
if (!date) return dateString; if (!date) return dateString;
const diffMs = date.getTime() - Date.now(); const now = Date.now();
const diffMs = date.getTime() - now;
const diffMinutes = Math.round(diffMs / 60000); const diffMinutes = Math.round(diffMs / 60000);
const diffHours = Math.round(diffMs / 3600000); const diffHours = Math.round(diffMs / 3600000);
const diffDays = Math.round(diffMs / 86400000); const diffDays = Math.round(diffMs / 86400000);
@ -54,9 +53,36 @@ export function formatRelativeTimestamp(dateString: string): string {
if (Math.abs(diffMinutes) < 60) if (Math.abs(diffMinutes) < 60)
return relativeTime.format(diffMinutes, "minute"); return relativeTime.format(diffMinutes, "minute");
if (Math.abs(diffHours) < 24) return relativeTime.format(diffHours, "hour"); if (Math.abs(diffHours) < 24) return relativeTime.format(diffHours, "hour");
if (Math.abs(diffDays) < 30) return relativeTime.format(diffDays, "day"); if (Math.abs(diffDays) < 14) return relativeTime.format(diffDays, "day");
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }).format( return formatDateOnly(dateString);
date, }
);
export function pickPlaceholder<T>(items: readonly T[]): T {
if (items.length === 1) return items[0]!;
let lastIndex = -1;
try {
lastIndex = Number.parseInt(
sessionStorage.getItem(LAST_PLACEHOLDER_KEY) ?? "",
10,
);
} catch {
lastIndex = -1;
}
let nextIndex = Math.floor(Math.random() * items.length);
if (nextIndex === lastIndex) {
nextIndex =
(nextIndex + 1 + Math.floor(Math.random() * (items.length - 1))) %
items.length;
}
try {
sessionStorage.setItem(LAST_PLACEHOLDER_KEY, String(nextIndex));
} catch {
// Ignore storage failures.
}
return items[nextIndex]!;
} }

47
src/lib/site.ts Normal file
View file

@ -0,0 +1,47 @@
const CLEARNET_HOST = "jetpham.com";
const ONION_HOST =
"jet7tetd43snvjx3ng5jrhuwm2yhyp76tjtct5mtofg64apokcgq7fqd.onion";
const COMMIT_BASE_URL = "https://git.extremist.software/jet/website/commit/";
const REPO_URL = "https://git.extremist.software/jet/website";
function isOnionHost(hostname: string): boolean {
return hostname.endsWith(".onion");
}
function getMirrorLink() {
if (isOnionHost(location.hostname)) {
return {
href: `https://${CLEARNET_HOST}`,
label: "clearnet",
};
}
return {
href: `http://${ONION_HOST}`,
label: ".onion",
};
}
export function renderFooter() {
const footer = document.getElementById("site-footer");
if (!footer) return;
const commitSha = __COMMIT_SHA__;
const shortSha = commitSha.slice(0, 7);
const mirror = getMirrorLink();
footer.innerHTML = `
<div class="site-panel px-[2ch] py-[1ch]">
<div class="site-footer-inner">
<span>rev <a href="${COMMIT_BASE_URL}${commitSha}">${shortSha}</a></span>
<span aria-hidden="true">|</span>
<a href="${REPO_URL}">src</a>
<span aria-hidden="true">|</span>
<a href="/pgp.txt" data-native-link>pgp</a>
<span aria-hidden="true">|</span>
<a href="/ssh.txt" data-native-link>ssh</a>
<span aria-hidden="true">|</span>
<a href="${mirror.href}">${mirror.label}</a>
</div>
</div>`;
}

View file

@ -1,13 +1,14 @@
import "~/styles/globals.css"; import "~/styles/globals.css";
import init, { start } from "cgol"; import init, { start } from "cgol";
import { route, initRouter } from "~/router"; import { route, initRouter } from "~/router";
import { renderFooter } from "~/lib/site";
import { homePage } from "~/pages/home"; import { homePage } from "~/pages/home";
import { qaPage } from "~/pages/qa"; import { qaPage } from "~/pages/qa";
import { notFoundPage } from "~/pages/not-found"; import { notFoundPage } from "~/pages/not-found";
route("/", homePage); route("/", "Jet Pham - Home", homePage);
route("/qa", qaPage); route("/qa", "Jet Pham - Q+A", qaPage);
route("*", notFoundPage); route("*", "404 - Jet Pham", notFoundPage);
try { try {
await init(); await init();
@ -17,3 +18,4 @@ try {
} }
initRouter(); initRouter();
renderFooter();

View file

@ -2,8 +2,9 @@ import Jet from "~/assets/Jet.txt?ansi";
import { frostedBox } from "~/components/frosted-box"; import { frostedBox } from "~/components/frosted-box";
export function homePage(outlet: HTMLElement) { export function homePage(outlet: HTMLElement) {
outlet.classList.remove("qa-outlet");
outlet.innerHTML = ` outlet.innerHTML = `
<div class="flex flex-col items-center justify-start px-4"> <div class="flex flex-col items-center justify-start">
${frostedBox(` ${frostedBox(`
<div class="flex flex-col items-center justify-center gap-[2ch] md:flex-row"> <div class="flex flex-col items-center justify-center gap-[2ch] md:flex-row">
<div class="order-1 flex flex-col items-center md:order-2"> <div class="order-1 flex flex-col items-center md:order-2">
@ -22,20 +23,43 @@ export function homePage(outlet: HTMLElement) {
/> />
</div> </div>
</div> </div>
<fieldset class="mt-[2ch] border-2 border-white px-[calc(1.5ch-0.5px)] pb-[1ch] pt-0"> <fieldset class="section-block mt-[2ch] border-2 border-white px-[calc(1.5ch-0.5px)] pb-[1ch] pt-0">
<legend class="-mx-[0.5ch] px-[0.5ch] text-white">Contact</legend> <legend class="-mx-[0.5ch] px-[0.5ch] text-white">Contact</legend>
<a href="mailto:jet@extremist.software">jet@extremist.software</a> <button type="button" id="copy-email" class="qa-inline-action">jet@extremist.software</button>
<span id="copy-email-status" class="qa-meta ml-[1ch]" aria-live="polite"></span>
</fieldset> </fieldset>
<fieldset class="mt-[2ch] border-2 border-white px-[calc(1.5ch-0.5px)] pb-[1ch] pt-0"> <fieldset class="section-block mt-[2ch] border-2 border-white px-[calc(1.5ch-0.5px)] pb-[1ch] pt-0">
<legend class="-mx-[0.5ch] px-[0.5ch] text-white">Links</legend> <legend class="-mx-[0.5ch] px-[0.5ch] text-white">Links</legend>
<ol> <ol>
<li><a href="https://git.extremist.software" class="inline-flex items-center">Forgejo</a></li> <li><a href="https://git.extremist.software" class="inline-flex items-center">Forgejo</a></li>
<li><a href="https://github.com/jetpham" class="inline-flex items-center">GitHub</a></li> <li><a href="https://github.com/jetpham" class="inline-flex items-center">GitHub</a></li>
<li><a href="https://x.com/exmistsoftware" class="inline-flex items-center">X</a></li> <li><a href="https://x.com/exmistsoftware" class="inline-flex items-center">X</a></li>
<li><a href="https://bsky.app/profile/extremist.software" class="inline-flex items-center">Bluesky</a></li> <li><a href="https://bsky.app/profile/extremist.software" class="inline-flex items-center">Bluesky</a></li>
<li><a href="http://jet7tetd43snvjx3ng5jrhuwm2yhyp76tjtct5mtofg64apokcgq7fqd.onion" class="inline-flex items-center">.onion</a></li>
</ol> </ol>
</fieldset> </fieldset>
`)} `)}
</div>`; </div>`;
const copyButton = document.getElementById("copy-email") as HTMLButtonElement;
const copyStatus = document.getElementById(
"copy-email-status",
) as HTMLSpanElement;
let resetTimer: number | null = null;
copyButton.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText("jet@extremist.software");
copyStatus.textContent = "copied";
copyStatus.style.color = "var(--light-green)";
} catch {
copyStatus.textContent = "copy failed";
copyStatus.style.color = "var(--light-red)";
}
if (resetTimer !== null) window.clearTimeout(resetTimer);
resetTimer = window.setTimeout(() => {
copyStatus.textContent = "";
resetTimer = null;
}, 1400);
});
} }

View file

@ -1,8 +1,9 @@
import { frostedBox } from "~/components/frosted-box"; import { frostedBox } from "~/components/frosted-box";
export function notFoundPage(outlet: HTMLElement) { export function notFoundPage(outlet: HTMLElement) {
outlet.classList.remove("qa-outlet");
outlet.innerHTML = ` outlet.innerHTML = `
<div class="flex flex-col items-center justify-start px-4"> <div class="flex flex-col items-center justify-start">
${frostedBox(` ${frostedBox(`
<h1 style="color: var(--light-red);">404</h1> <h1 style="color: var(--light-red);">404</h1>
<p class="mt-[1ch]">Page not found.</p> <p class="mt-[1ch]">Page not found.</p>

View file

@ -1,82 +1,110 @@
import { getQuestions, submitQuestion, type Question } from "~/lib/api"; import {
getQuestions,
getQuestionStats,
submitQuestion,
type Question,
type QuestionStats,
} from "~/lib/api";
import { import {
clearQuestionDraft, clearQuestionDraft,
formatExactTimestamp, formatDateOnly,
formatRelativeTimestamp, pickPlaceholder,
readQuestionDraft, readQuestionDraft,
writeQuestionDraft, writeQuestionDraft,
} from "~/lib/qa"; } from "~/lib/qa";
import { frostedBox } from "~/components/frosted-box"; import { frostedBox } from "~/components/frosted-box";
const PLACEHOLDER_QUESTIONS = [
"Why call yourself a software extremist?",
"What are you building at Noisebridge?",
"Why Forgejo over GitHub?",
"What is the weirdest thing in your nix-config?",
"Why did you write HolyC?",
"What do you like about San Francisco hacker culture?",
"What is your favorite project you've seen at TIAT?",
"What is your favorite project you've seen at Noisebridge?",
"What is your favorite hacker conference?",
"What is your cat's name?",
"What are your favorite programming languages and tools?",
"Who are your biggest inspirations?",
] as const;
function escapeHtml(str: string): string { function escapeHtml(str: string): string {
const div = document.createElement("div"); const div = document.createElement("div");
div.textContent = str; div.textContent = str;
return div.innerHTML; return div.innerHTML;
} }
function formatQuestionDates(question: Question): string { function formatQuestionTooltip(question: Question): string {
const asked = formatRelativeTimestamp(question.created_at); const askedExact = formatDateOnly(question.created_at);
const answered = formatRelativeTimestamp(question.answered_at); const answeredExact = formatDateOnly(question.answered_at);
const askedExact = formatExactTimestamp(question.created_at);
const answeredExact = formatExactTimestamp(question.answered_at);
return ` return `
<span title="${escapeHtml(askedExact)}">Asked ${escapeHtml(asked)}</span> <p>Asked ${escapeHtml(askedExact)}</p>
&middot; <p>Answered ${escapeHtml(answeredExact)}</p>`;
<span title="${escapeHtml(answeredExact)}">Answered ${escapeHtml(answered)}</span>`; }
function formatRatio(stats: QuestionStats): string {
if (stats.asked === 0) return "0%";
return `${Math.round((stats.answered / stats.asked) * 100)}%`;
} }
function renderQuestions(list: HTMLElement, questions: Question[]) { function renderQuestions(list: HTMLElement, questions: Question[]) {
if (questions.length === 0) { if (questions.length === 0) {
list.innerHTML = ` list.innerHTML = `
<fieldset class="border-2 border-white px-[calc(1.5ch-0.5px)] pb-[1ch] pt-0"> <section class="qa-item qa-list-item 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 class="qa-list-label">No answers yet</p>
<p>No questions have been answered yet.</p> <p>...</p>
<p class="qa-meta">Ask something above and check back once a reply is posted.</p> </section>`;
</fieldset>`;
return; return;
} }
list.innerHTML = questions list.innerHTML = questions
.map( .map(
(q) => ` (q) => `
<fieldset class="border-2 border-white px-[calc(1.5ch-0.5px)] pb-[1ch] pt-0 mb-[2ch]"> <section class="qa-item qa-list-item mb-[2ch] px-[calc(1.5ch-0.5px)] pb-[1ch] pt-0" tabindex="0">
<legend class="-mx-[0.5ch] px-[0.5ch]" style="color: var(--dark-gray);">#${String(q.id)}</legend> <div class="qa-item-meta" role="note">
${formatQuestionTooltip(q)}
</div>
<p style="color: var(--light-cyan);">${escapeHtml(q.question)}</p> <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-[1ch]" style="color: var(--light-green);">${escapeHtml(q.answer)}</p>
<p class="qa-meta">${formatQuestionDates(q)}</p> </section>`,
</fieldset>`,
) )
.join(""); .join("");
} }
export async function qaPage(outlet: HTMLElement) { export async function qaPage(outlet: HTMLElement) {
outlet.classList.add("qa-outlet");
const draft = readQuestionDraft(); const draft = readQuestionDraft();
const placeholderQuestion = pickPlaceholder(PLACEHOLDER_QUESTIONS);
outlet.innerHTML = ` outlet.innerHTML = `
<div class="flex flex-col items-center justify-start px-4"> <div class="qa-page flex h-full flex-col items-center justify-start">
${frostedBox(` ${frostedBox(
`
<div class="flex h-full flex-col">
<form id="qa-form" novalidate> <form id="qa-form" novalidate>
<fieldset class="border-2 border-white px-[calc(1.5ch-0.5px)] pb-[1ch] pt-[1ch]"> <section class="section-block">
<legend class="-mx-[0.5ch] px-[0.5ch] text-white">Ask a Question</legend>
<label class="sr-only" for="qa-input">Question</label> <label class="sr-only" for="qa-input">Question</label>
<div class="qa-input-wrap">
<textarea id="qa-input" maxlength="200" rows="3" <textarea id="qa-input" maxlength="200" rows="3"
class="qa-textarea" class="qa-textarea"
aria-describedby="qa-helper qa-validation char-count" aria-describedby="qa-status char-count"
placeholder="Type your question...">${escapeHtml(draft)}</textarea> placeholder="${escapeHtml(placeholderQuestion)}">${escapeHtml(draft)}</textarea>
<p id="qa-helper" class="qa-helper">Press Enter to send. Press Shift+Enter for a new line.</p> <div class="qa-input-bar" aria-hidden="true">
<div class="mt-[0.5ch] flex justify-between gap-[1ch]"> <span id="char-count" class="qa-bar-text">${draft.length}/200</span>
<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> <button id="qa-submit" type="submit" class="qa-button">[SUBMIT]</button>
</div> </div>
</fieldset> </div>
<p id="qa-status" class="qa-meta" aria-live="polite"></p>
<div id="qa-stats" class="qa-stats mt-[1ch]" aria-live="polite">Asked ... | Answered ... | Ratio ...</div>
</section>
</form> </form>
<div id="qa-list" class="mt-[2ch]" aria-live="polite">Loading answered questions...</div> <div id="qa-list" class="qa-list-scroll mt-[2ch] min-h-0 flex-1 overflow-y-auto pr-[1ch]" aria-live="polite">Loading answered questions...</div>
`)} </div>
`,
"my-0 flex h-full min-h-0 flex-col",
)}
</div>`; </div>`;
const form = document.getElementById("qa-form") as HTMLFormElement; const form = document.getElementById("qa-form") as HTMLFormElement;
@ -85,28 +113,46 @@ export async function qaPage(outlet: HTMLElement) {
"qa-submit", "qa-submit",
) as HTMLButtonElement; ) as HTMLButtonElement;
const charCount = document.getElementById("char-count") as HTMLSpanElement; 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 status = document.getElementById("qa-status") as HTMLParagraphElement;
const stats = document.getElementById("qa-stats") as HTMLDivElement;
const list = document.getElementById("qa-list") as HTMLDivElement; const list = document.getElementById("qa-list") as HTMLDivElement;
let isSubmitting = false; let isSubmitting = false;
let hasInteracted = draft.trim().length > 0; let hasInteracted = draft.trim().length > 0;
let buttonResetTimer: number | null = null;
const defaultButtonText = "[SUBMIT]";
function setStatus(message: string, color: string) { function setStatus(message: string, color: string) {
status.textContent = message; status.textContent = message;
status.style.color = color; status.style.color = color;
} }
function setStatsDisplay(nextStats: QuestionStats) {
stats.textContent = `Asked ${nextStats.asked} | Answered ${nextStats.answered} | Ratio ${formatRatio(nextStats)}`;
}
function showButtonMessage(message: string, color: string, duration = 1600) {
if (buttonResetTimer !== null) window.clearTimeout(buttonResetTimer);
submitButton.textContent = message;
submitButton.style.color = color;
submitButton.classList.add("qa-button-message");
buttonResetTimer = window.setTimeout(() => {
buttonResetTimer = null;
submitButton.textContent = defaultButtonText;
submitButton.style.color = "";
submitButton.classList.remove("qa-button-message");
if (!input.matches(":focus") && input.value.trim().length === 0) {
input.removeAttribute("aria-invalid");
}
}, duration);
}
function updateValidation() { function updateValidation() {
const trimmed = input.value.trim(); const trimmed = input.value.trim();
const remaining = 200 - input.value.length; const remaining = 200 - input.value.length;
charCount.textContent = `${input.value.length}/200`; charCount.textContent = `${input.value.length}/200`;
if (trimmed.length === 0 && hasInteracted) { if (trimmed.length === 0 && hasInteracted) {
validation.textContent = "Question cannot be empty.";
validation.style.color = "var(--light-red)";
input.setAttribute("aria-invalid", "true"); input.setAttribute("aria-invalid", "true");
return false; return false;
} }
@ -114,13 +160,16 @@ export async function qaPage(outlet: HTMLElement) {
input.removeAttribute("aria-invalid"); input.removeAttribute("aria-invalid");
if (remaining <= 20) { if (remaining <= 20) {
validation.textContent = `${remaining} characters left.`; charCount.style.color =
validation.style.color = remaining === 0
remaining <= 5 ? "var(--yellow)" : "var(--dark-gray)"; ? "var(--light-red)"
: remaining <= 5
? "var(--yellow)"
: "var(--dark-gray)";
return true; return true;
} }
validation.textContent = ""; charCount.style.color = "var(--dark-gray)";
return true; return true;
} }
@ -133,14 +182,15 @@ export async function qaPage(outlet: HTMLElement) {
const questions = await getQuestions(); const questions = await getQuestions();
renderQuestions(list, questions); renderQuestions(list, questions);
} catch { } catch {
showButtonMessage("[LOAD FAILED]", "var(--light-red)");
list.innerHTML = ` list.innerHTML = `
<fieldset class="border-2 border-white px-[calc(1.5ch-0.5px)] pb-[1ch] pt-0"> <section class="qa-item qa-list-item 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 class="qa-list-label" style="color: var(--light-red);">Load failed</p>
<p>Failed to load answered questions.</p> <p>Failed to load answered questions.</p>
<p class="qa-meta"> <p class="qa-meta">
<button type="button" id="qa-retry" class="qa-inline-action">Retry loading questions</button> <button type="button" id="qa-retry" class="qa-inline-action">Retry loading questions</button>
</p> </p>
</fieldset>`; </section>`;
list.style.color = "var(--light-red)"; list.style.color = "var(--light-red)";
const retryButton = document.getElementById( const retryButton = document.getElementById(
@ -152,6 +202,15 @@ export async function qaPage(outlet: HTMLElement) {
} }
} }
async function loadStats() {
try {
const nextStats = await getQuestionStats();
setStatsDisplay(nextStats);
} catch {
stats.textContent = "Asked ? | Answered ? | Ratio ?";
}
}
input.addEventListener("input", () => { input.addEventListener("input", () => {
hasInteracted = true; hasInteracted = true;
writeQuestionDraft(input.value); writeQuestionDraft(input.value);
@ -172,13 +231,15 @@ export async function qaPage(outlet: HTMLElement) {
const question = input.value.trim(); const question = input.value.trim();
hasInteracted = true; hasInteracted = true;
if (!updateValidation() || !question) { if (!updateValidation() || !question) {
setStatus("Write a question before submitting.", "var(--light-red)"); setStatus("", "var(--dark-gray)");
showButtonMessage("[EMPTY]", "var(--light-red)");
input.focus(); input.focus();
return; return;
} }
isSubmitting = true; isSubmitting = true;
submitButton.disabled = true; submitButton.disabled = true;
submitButton.textContent = "[SENDING]";
setStatus("Submitting...", "var(--light-gray)"); setStatus("Submitting...", "var(--light-gray)");
submitQuestion(question) submitQuestion(question)
@ -186,6 +247,8 @@ export async function qaPage(outlet: HTMLElement) {
input.value = ""; input.value = "";
clearQuestionDraft(); clearQuestionDraft();
hasInteracted = false; hasInteracted = false;
submitButton.textContent = defaultButtonText;
submitButton.style.color = "";
updateValidation(); updateValidation();
setStatus( setStatus(
"Question submitted! It will appear here once answered.", "Question submitted! It will appear here once answered.",
@ -194,17 +257,27 @@ export async function qaPage(outlet: HTMLElement) {
input.focus(); input.focus();
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
setStatus( const message =
err instanceof Error ? err.message : "Failed to submit question.", err instanceof Error ? err.message : "Failed to submit question.";
"var(--light-red)", if (message.includes("Too many questions")) {
); showButtonMessage("[RATE LIMIT]", "var(--light-red)");
setStatus(message, "var(--light-red)");
} else {
showButtonMessage("[FAILED]", "var(--light-red)");
setStatus("", "var(--dark-gray)");
}
}) })
.finally(() => { .finally(() => {
isSubmitting = false; isSubmitting = false;
submitButton.disabled = false; submitButton.disabled = false;
if (buttonResetTimer === null) {
submitButton.textContent = defaultButtonText;
submitButton.style.color = "";
}
}); });
}); });
updateValidation(); updateValidation();
await loadStats();
await loadQuestions(); await loadQuestions();
} }

View file

@ -7,14 +7,17 @@ interface Route {
pattern: RegExp; pattern: RegExp;
keys: string[]; keys: string[];
handler: PageHandler; handler: PageHandler;
title: string;
} }
const routes: Route[] = []; const routes: Route[] = [];
let notFoundHandler: PageHandler | null = null; let notFoundHandler: PageHandler | null = null;
let notFoundTitle = "404 - Jet Pham";
export function route(path: string, handler: PageHandler) { export function route(path: string, title: string, handler: PageHandler) {
if (path === "*") { if (path === "*") {
notFoundHandler = handler; notFoundHandler = handler;
notFoundTitle = title;
return; return;
} }
const keys: string[] = []; const keys: string[] = [];
@ -22,13 +25,13 @@ export function route(path: string, handler: PageHandler) {
keys.push(key); keys.push(key);
return "([^/]+)"; return "([^/]+)";
}); });
routes.push({ pattern: new RegExp(`^${pattern}$`), keys, handler }); routes.push({ pattern: new RegExp(`^${pattern}$`), keys, handler, title });
} }
export function navigate(path: string) { export function navigate(path: string) {
history.pushState(null, "", path); history.pushState(null, "", path);
window.scrollTo({ top: 0, behavior: "auto" }); window.scrollTo({ top: 0, behavior: "auto" });
void render(true); void render();
} }
function updateNavState(path: string) { function updateNavState(path: string) {
@ -43,7 +46,7 @@ function updateNavState(path: string) {
}); });
} }
async function render(focusOutlet = false) { async function render() {
const path = location.pathname; const path = location.pathname;
const outlet = document.getElementById("outlet")!; const outlet = document.getElementById("outlet")!;
updateNavState(path); updateNavState(path);
@ -57,7 +60,7 @@ async function render(focusOutlet = false) {
}); });
outlet.innerHTML = ""; outlet.innerHTML = "";
await r.handler(outlet, params); await r.handler(outlet, params);
if (focusOutlet) outlet.focus(); document.title = r.title;
return; return;
} }
} }
@ -66,17 +69,18 @@ async function render(focusOutlet = false) {
if (notFoundHandler) { if (notFoundHandler) {
await notFoundHandler(outlet, {}); await notFoundHandler(outlet, {});
} }
if (focusOutlet) outlet.focus(); document.title = notFoundTitle;
} }
export function initRouter() { export function initRouter() {
window.addEventListener("popstate", () => void render(true)); window.addEventListener("popstate", () => void render());
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 ( if (
anchor?.origin === location.origin && anchor?.origin === location.origin &&
!anchor.hash && !anchor.hash &&
!anchor.hasAttribute("data-native-link") &&
!anchor.hasAttribute("download") && !anchor.hasAttribute("download") &&
!anchor.hasAttribute("target") !anchor.hasAttribute("target")
) { ) {

View file

@ -35,40 +35,80 @@
--white: #ffffff; --white: #ffffff;
} }
/* Global BBS-style 80-character width constraint - responsive */
html { html {
height: 100%; min-height: 100%;
width: min( box-sizing: border-box;
80ch, }
100vw
); /* 80 characters wide on desktop, full width on mobile */ body {
padding: 1rem; min-height: 100vh;
margin: 0 auto; min-height: 100dvh;
margin: 0;
overflow: hidden;
background-color: var(--black);
color: var(--white);
font-family: "IBM VGA", monospace; font-family: "IBM VGA", monospace;
font-size: 1.25rem; /* Smaller font size for mobile */ font-size: 1.25rem;
white-space: normal; white-space: normal;
line-height: 1; line-height: 1;
box-sizing: border-box; }
*,
*::before,
*::after {
box-sizing: inherit;
} }
/* Desktop font size */ /* Desktop font size */
@media (min-width: 768px) { @media (min-width: 768px) {
html { body {
font-size: 1.5rem; font-size: 1.5rem;
} }
} }
/* Apply CGA theme to body */ .page-frame {
body { height: 100vh;
height: 100%; height: 100dvh;
background-color: var(--black); width: min(80ch, 100vw);
color: var(--white); margin: 0 auto;
margin: 0; padding: 1rem;
padding: 0; display: grid;
grid-template-rows: auto 1fr auto;
gap: 2ch;
} }
#outlet { #outlet {
display: block; display: block;
min-height: 0;
overflow: hidden;
user-select: none;
}
#outlet.qa-outlet {
overflow: hidden;
}
#outlet:focus {
outline: none;
}
.site-shell {
width: min(100%, 66.666667%);
box-sizing: border-box;
margin: 0 auto;
user-select: text;
}
.site-panel {
border: 2px solid var(--white);
background-color: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
overflow: hidden;
}
.site-region {
width: 100%;
} }
/* Global focus ring styles for all tabbable elements */ /* Global focus ring styles for all tabbable elements */
@ -99,10 +139,10 @@ a[aria-current="page"] {
text-decoration: underline; text-decoration: underline;
} }
a[href^="http://"]::after, a[aria-current="page"] {
a[href^="https://"]::after { color: var(--yellow);
content: " [EXT]"; background-color: transparent;
color: var(--dark-gray); text-decoration: underline;
} }
.skip-link { .skip-link {
@ -121,12 +161,59 @@ a[href^="https://"]::after {
} }
/* Form inputs */ /* Form inputs */
.qa-input-wrap {
position: relative;
padding: 1ch;
background-color: rgba(0, 0, 0, 0.18);
border: 2px solid var(--white);
}
.qa-page {
min-height: 0;
width: 100%;
}
.qa-list-scroll {
padding-top: 1.5ch;
padding-bottom: 1.5ch;
scrollbar-gutter: stable;
overscroll-behavior: contain;
padding-right: 0.5ch;
}
.qa-list-scroll::-webkit-scrollbar {
width: 1ch;
}
.qa-list-scroll::-webkit-scrollbar-track {
background: transparent;
}
.qa-list-scroll::-webkit-scrollbar-thumb {
background-color: var(--dark-gray);
border-left: 1px solid transparent;
border-right: 1px solid transparent;
}
.qa-list-scroll::-webkit-scrollbar-thumb:hover {
background-color: var(--light-gray);
}
.qa-stats {
color: var(--dark-gray);
text-align: center;
}
.qa-textarea { .qa-textarea {
width: 100%; width: 100%;
background-color: var(--black); background-color: transparent;
border: 2px solid var(--white); border: none;
caret-color: var(--white);
color: var(--light-gray); color: var(--light-gray);
padding: 1ch; padding: 0;
padding-right: 14ch;
padding-bottom: 4ch;
overflow-y: auto;
resize: none; resize: none;
font-family: inherit; font-family: inherit;
font-size: inherit; font-size: inherit;
@ -137,6 +224,24 @@ a[href^="https://"]::after {
color: var(--dark-gray); color: var(--dark-gray);
} }
.qa-input-bar {
position: absolute;
right: 2px;
bottom: 2px;
left: 2px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1ch;
padding: 0 1ch 1ch;
pointer-events: none;
background: linear-gradient(to top, rgba(0, 0, 0, 0.82), transparent);
}
.qa-bar-text {
color: var(--dark-gray);
}
.qa-button { .qa-button {
border: none; border: none;
padding: 0.25ch 1ch; padding: 0.25ch 1ch;
@ -145,6 +250,7 @@ a[href^="https://"]::after {
font-family: inherit; font-family: inherit;
font-size: inherit; font-size: inherit;
cursor: pointer; cursor: pointer;
pointer-events: auto;
} }
.qa-button:hover { .qa-button:hover {
@ -152,6 +258,10 @@ a[href^="https://"]::after {
color: var(--black); color: var(--black);
} }
.qa-button-message:hover {
background: transparent;
}
.qa-button:disabled { .qa-button:disabled {
color: var(--dark-gray); color: var(--dark-gray);
cursor: wait; cursor: wait;
@ -162,16 +272,48 @@ a[href^="https://"]::after {
color: var(--dark-gray); color: var(--dark-gray);
} }
.qa-helper {
margin-top: 1ch;
color: var(--dark-gray);
}
.qa-meta { .qa-meta {
margin-top: 0.5ch; margin-top: 0.5ch;
color: var(--dark-gray); color: var(--dark-gray);
} }
.qa-item {
position: relative;
}
.qa-list-item {
border-top: none;
}
.qa-list-item + .qa-list-item {
margin-top: 1.5ch;
padding-top: 1.5ch;
border-top: 2px solid var(--white);
}
.qa-list-label {
margin-bottom: 1ch;
color: var(--white);
}
.qa-item-meta {
position: absolute;
top: 0;
right: 0;
display: none;
padding: 0.75ch 1ch;
background-color: rgba(0, 0, 0, 0.9);
box-shadow: inset 0 0 0 1px var(--dark-gray);
color: var(--light-gray);
z-index: 5;
}
.qa-item:hover .qa-item-meta,
.qa-item:focus-within .qa-item-meta,
.qa-item:focus .qa-item-meta {
display: block;
}
.qa-inline-action { .qa-inline-action {
border: none; border: none;
background: transparent; background: transparent;
@ -189,6 +331,23 @@ a[href^="https://"]::after {
color: var(--black); color: var(--black);
} }
.qa-textarea::selection {
background-color: var(--light-blue);
color: var(--black);
}
.qa-textarea:focus {
outline: none;
}
.site-footer-inner {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 1ch;
color: var(--dark-gray);
}
.sr-only { .sr-only {
position: absolute; position: absolute;
width: 1px; width: 1px;

View file

@ -1,4 +1,5 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import { execSync } from "node:child_process";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import wasm from "vite-plugin-wasm"; import wasm from "vite-plugin-wasm";
import topLevelAwait from "vite-plugin-top-level-await"; import topLevelAwait from "vite-plugin-top-level-await";
@ -6,7 +7,12 @@ import { viteSingleFile } from "vite-plugin-singlefile";
import ansi from "./vite-plugin-ansi"; import ansi from "./vite-plugin-ansi";
import markdown from "./vite-plugin-markdown"; import markdown from "./vite-plugin-markdown";
const commitSha = execSync("git rev-parse HEAD", { encoding: "utf8" }).trim();
export default defineConfig({ export default defineConfig({
define: {
__COMMIT_SHA__: JSON.stringify(commitSha),
},
plugins: [ plugins: [
ansi(), ansi(),
markdown(), markdown(),