Compare commits
No commits in common. "d76a44aa02d0ebfdafdf66d2d153627b4141f8a5" and "691394445a7e0ca8b4a738f3ed024da135c165c3" have entirely different histories.
d76a44aa02
...
691394445a
17 changed files with 145 additions and 690 deletions
|
|
@ -19,12 +19,6 @@ pub struct Question {
|
|||
answered_at: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct QuestionStats {
|
||||
asked: i64,
|
||||
answered: i64,
|
||||
}
|
||||
|
||||
pub async fn get_questions(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<Json<Vec<Question>>, StatusCode> {
|
||||
|
|
@ -57,29 +51,6 @@ pub async fn get_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)]
|
||||
pub struct SubmitQuestion {
|
||||
question: String,
|
||||
|
|
@ -220,12 +191,10 @@ pub struct MtaHookResponse {
|
|||
}
|
||||
|
||||
fn webhook_secret_matches(headers: &HeaderMap, expected_secret: &str) -> bool {
|
||||
let expected_secret = expected_secret.trim();
|
||||
let header_secret = headers
|
||||
.get("X-Webhook-Secret")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
.unwrap_or("");
|
||||
if header_secret == expected_secret {
|
||||
return true;
|
||||
}
|
||||
|
|
@ -255,29 +224,7 @@ fn webhook_secret_matches(headers: &HeaderMap, expected_secret: &str) -> bool {
|
|||
None => return false,
|
||||
};
|
||||
|
||||
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:?}"
|
||||
)
|
||||
password == expected_secret
|
||||
}
|
||||
|
||||
fn extract_qa_reply(payload: &MtaHookPayload, expected_domain: &str) -> Option<(i64, String)> {
|
||||
|
|
@ -357,21 +304,6 @@ 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)> {
|
||||
if let Some(messages) = payload.get("messages").and_then(Value::as_array) {
|
||||
for message in messages {
|
||||
|
|
@ -386,7 +318,6 @@ fn extract_qa_reply_from_value(payload: &Value, expected_domain: &str) -> Option
|
|||
&["headers", "subject"],
|
||||
],
|
||||
)
|
||||
.or_else(|| subject_from_headers_value(value_at_path(message, &["message", "headers"])))
|
||||
.as_deref(),
|
||||
&string_at_paths(message, &[&["message", "contents"], &["contents"], &["raw_message"]])
|
||||
.unwrap_or_default(),
|
||||
|
|
@ -407,7 +338,6 @@ fn extract_qa_reply_from_value(payload: &Value, expected_domain: &str) -> Option
|
|||
&["headers", "subject"],
|
||||
],
|
||||
)
|
||||
.or_else(|| subject_from_headers_value(value_at_path(payload, &["message", "headers"])))
|
||||
.as_deref(),
|
||||
&string_at_paths(payload, &[&["message", "contents"], &["contents"], &["raw_message"]])
|
||||
.unwrap_or_default(),
|
||||
|
|
@ -450,11 +380,7 @@ pub async fn webhook(
|
|||
body: String,
|
||||
) -> Result<Json<MtaHookResponse>, (StatusCode, String)> {
|
||||
if !webhook_secret_matches(&headers, &state.webhook_secret) {
|
||||
eprintln!(
|
||||
"Rejected webhook: invalid secret; expected_len={}; {}",
|
||||
state.webhook_secret.len(),
|
||||
webhook_secret_debug(&headers)
|
||||
);
|
||||
eprintln!("Rejected webhook: invalid secret");
|
||||
return Err((StatusCode::UNAUTHORIZED, "invalid secret".to_string()));
|
||||
}
|
||||
|
||||
|
|
@ -589,28 +515,4 @@ mod tests {
|
|||
Some((9, "Answer body".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_reply_from_value_with_header_pairs() {
|
||||
let payload: Value = serde_json::from_str(
|
||||
r#"{
|
||||
"envelope": {
|
||||
"to": [{"address":"qa@extremist.software"}]
|
||||
},
|
||||
"message": {
|
||||
"headers": [
|
||||
["From", " jet@extremist.software\r\n"],
|
||||
["Subject", " Re: 11 - hi\r\n"]
|
||||
],
|
||||
"contents": "Answer from header pairs"
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
extract_qa_reply_from_value(&payload, "extremist.software"),
|
||||
Some((11, "Answer from header pairs".to_string()))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,7 +52,6 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||
"/api/questions",
|
||||
get(handlers::get_questions).post(handlers::post_question),
|
||||
)
|
||||
.route("/api/questions/stats", get(handlers::get_question_stats))
|
||||
.route("/api/webhook", post(handlers::webhook))
|
||||
.layer(CorsLayer::permissive())
|
||||
.with_state(state);
|
||||
|
|
|
|||
28
index.html
28
index.html
|
|
@ -106,20 +106,30 @@
|
|||
class="fixed top-0 left-0 -z-10 h-screen w-screen"
|
||||
aria-hidden="true"
|
||||
></canvas>
|
||||
<div class="page-frame">
|
||||
<nav aria-label="Main navigation" class="site-region">
|
||||
<div class="site-shell site-panel px-[2ch] py-[1ch]">
|
||||
<div class="flex justify-center gap-[2ch]">
|
||||
<nav aria-label="Main navigation" class="flex justify-center px-4">
|
||||
<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);
|
||||
"
|
||||
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="/qa" data-nav-link>[Q&A]</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<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>
|
||||
<main id="outlet" tabindex="-1"></main>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
# Jet Pham SSH fingerprints
|
||||
ssh-ed25519 SHA256:Ziw7a2bUA1ew4AFQLB8rk9G3l9I4/eRClf9OJMLMLUA
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
export function frostedBox(content: string, extraClasses?: string): string {
|
||||
return `
|
||||
<div class="site-shell relative my-[2ch] overflow-hidden px-[2ch] py-[2ch] ${extraClasses ?? ""}">
|
||||
<div class="relative px-[2ch] py-[2ch] my-[2ch] w-full max-w-[66.666667%] min-w-fit ${extraClasses ?? ""}">
|
||||
<div
|
||||
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%);"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
<div class="absolute inset-0 border-2 border-white" aria-hidden="true"></div>
|
||||
<div class="relative z-10 h-full min-h-0">
|
||||
<div class="relative z-10">
|
||||
${content}
|
||||
</div>
|
||||
</div>`;
|
||||
|
|
|
|||
2
src/global.d.ts
vendored
2
src/global.d.ts
vendored
|
|
@ -1,7 +1,5 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
declare const __COMMIT_SHA__: string;
|
||||
|
||||
declare module "*.txt?raw" {
|
||||
const content: string;
|
||||
export default content;
|
||||
|
|
|
|||
109
src/lib/api.ts
109
src/lib/api.ts
|
|
@ -6,118 +6,12 @@ export interface Question {
|
|||
answered_at: string;
|
||||
}
|
||||
|
||||
export interface QuestionStats {
|
||||
asked: number;
|
||||
answered: number;
|
||||
}
|
||||
|
||||
const DEV_QUESTIONS: Question[] = [
|
||||
{
|
||||
id: 1,
|
||||
question: "What is a fact about octopuses?",
|
||||
answer: "An octopus has three hearts and blue blood.",
|
||||
created_at: "2026-03-23T18:10:00.000Z",
|
||||
answered_at: "2026-03-23T19:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
question: "What is a fact about axolotls?",
|
||||
answer:
|
||||
"An axolotl can regrow limbs, parts of its heart, and even parts of its brain.",
|
||||
created_at: "2026-03-24T02:15:00.000Z",
|
||||
answered_at: "2026-03-24T05:45:00.000Z",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
question: "What is a fact about crows?",
|
||||
answer: "Crows can recognize human faces and remember them for years.",
|
||||
created_at: "2026-03-25T08:30:00.000Z",
|
||||
answered_at: "2026-03-25T09:05:00.000Z",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
question: "What is a fact about wombats?",
|
||||
answer: "Wombats produce cube-shaped poop.",
|
||||
created_at: "2026-03-25T11:10:00.000Z",
|
||||
answered_at: "2026-03-25T11:40:00.000Z",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
question: "What is a fact about mantis shrimp?",
|
||||
answer:
|
||||
"A mantis shrimp can punch so fast it creates tiny cavitation bubbles in water.",
|
||||
created_at: "2026-03-25T13:00:00.000Z",
|
||||
answered_at: "2026-03-25T13:18:00.000Z",
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
question: "What is a fact about sloths?",
|
||||
answer:
|
||||
"Some sloths can hold their breath longer than dolphins by slowing their heart rate.",
|
||||
created_at: "2026-03-25T14:25:00.000Z",
|
||||
answered_at: "2026-03-25T15:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
question: "What is a fact about owls?",
|
||||
answer:
|
||||
"An owl cannot rotate its eyes, so it turns its whole head instead.",
|
||||
created_at: "2026-03-25T16:05:00.000Z",
|
||||
answered_at: "2026-03-25T16:21:00.000Z",
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
question: "What is a fact about capybaras?",
|
||||
answer:
|
||||
"Capybaras are the largest rodents in the world and are excellent swimmers.",
|
||||
created_at: "2026-03-25T18:45:00.000Z",
|
||||
answered_at: "2026-03-25T19:07:00.000Z",
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
question: "What is a fact about penguins?",
|
||||
answer: "Penguins have solid bones, which help them dive instead of float.",
|
||||
created_at: "2026-03-25T21:20:00.000Z",
|
||||
answered_at: "2026-03-25T21:55:00.000Z",
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
question: "What is a fact about bats?",
|
||||
answer: "Bats are the only mammals capable of sustained powered flight.",
|
||||
created_at: "2026-03-26T00:10:00.000Z",
|
||||
answered_at: "2026-03-26T00:32:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
const DEV_QUESTION_STATS: QuestionStats = {
|
||||
asked: 16,
|
||||
answered: DEV_QUESTIONS.length,
|
||||
};
|
||||
|
||||
export async function getQuestions(): Promise<Question[]> {
|
||||
if (import.meta.env.DEV) return DEV_QUESTIONS;
|
||||
|
||||
const res = await fetch("/api/questions");
|
||||
if (!res.ok) throw new Error("Failed to fetch questions");
|
||||
return res.json() as Promise<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> {
|
||||
const res = await fetch("/api/questions", {
|
||||
method: "POST",
|
||||
|
|
@ -125,8 +19,7 @@ export async function submitQuestion(question: string): Promise<void> {
|
|||
body: JSON.stringify({ question }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (res.status === 429)
|
||||
throw new Error("Too many questions. Please try again later.");
|
||||
if (res.status === 429) throw new Error("Too many questions. Please try again later.");
|
||||
throw new Error("Failed to submit question");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
const DRAFT_KEY = "qa-draft";
|
||||
const LAST_PLACEHOLDER_KEY = "qa-last-placeholder";
|
||||
const relativeTime = new Intl.RelativeTimeFormat(undefined, {
|
||||
numeric: "auto",
|
||||
});
|
||||
|
|
@ -33,19 +32,21 @@ export function clearQuestionDraft() {
|
|||
}
|
||||
}
|
||||
|
||||
export function formatDateOnly(dateString: string): string {
|
||||
export function formatExactTimestamp(dateString: string): string {
|
||||
const date = getValidDate(dateString);
|
||||
if (!date) return dateString;
|
||||
|
||||
return date.toISOString().slice(0, 10);
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function formatFriendlyDate(dateString: string): string {
|
||||
export function formatRelativeTimestamp(dateString: string): string {
|
||||
const date = getValidDate(dateString);
|
||||
if (!date) return dateString;
|
||||
|
||||
const now = Date.now();
|
||||
const diffMs = date.getTime() - now;
|
||||
const diffMs = date.getTime() - Date.now();
|
||||
const diffMinutes = Math.round(diffMs / 60000);
|
||||
const diffHours = Math.round(diffMs / 3600000);
|
||||
const diffDays = Math.round(diffMs / 86400000);
|
||||
|
|
@ -53,36 +54,9 @@ export function formatFriendlyDate(dateString: string): string {
|
|||
if (Math.abs(diffMinutes) < 60)
|
||||
return relativeTime.format(diffMinutes, "minute");
|
||||
if (Math.abs(diffHours) < 24) return relativeTime.format(diffHours, "hour");
|
||||
if (Math.abs(diffDays) < 14) return relativeTime.format(diffDays, "day");
|
||||
if (Math.abs(diffDays) < 30) return relativeTime.format(diffDays, "day");
|
||||
|
||||
return formatDateOnly(dateString);
|
||||
}
|
||||
|
||||
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,
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }).format(
|
||||
date,
|
||||
);
|
||||
} 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]!;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,47 +0,0 @@
|
|||
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>`;
|
||||
}
|
||||
|
|
@ -1,14 +1,13 @@
|
|||
import "~/styles/globals.css";
|
||||
import init, { start } from "cgol";
|
||||
import { route, initRouter } from "~/router";
|
||||
import { renderFooter } from "~/lib/site";
|
||||
import { homePage } from "~/pages/home";
|
||||
import { qaPage } from "~/pages/qa";
|
||||
import { notFoundPage } from "~/pages/not-found";
|
||||
|
||||
route("/", "Jet Pham - Home", homePage);
|
||||
route("/qa", "Jet Pham - Q+A", qaPage);
|
||||
route("*", "404 - Jet Pham", notFoundPage);
|
||||
route("/", homePage);
|
||||
route("/qa", qaPage);
|
||||
route("*", notFoundPage);
|
||||
|
||||
try {
|
||||
await init();
|
||||
|
|
@ -18,4 +17,3 @@ try {
|
|||
}
|
||||
|
||||
initRouter();
|
||||
renderFooter();
|
||||
|
|
|
|||
|
|
@ -2,9 +2,8 @@ import Jet from "~/assets/Jet.txt?ansi";
|
|||
import { frostedBox } from "~/components/frosted-box";
|
||||
|
||||
export function homePage(outlet: HTMLElement) {
|
||||
outlet.classList.remove("qa-outlet");
|
||||
outlet.innerHTML = `
|
||||
<div class="flex flex-col items-center justify-start">
|
||||
<div class="flex flex-col items-center justify-start px-4">
|
||||
${frostedBox(`
|
||||
<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">
|
||||
|
|
@ -23,43 +22,20 @@ export function homePage(outlet: HTMLElement) {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<fieldset class="section-block mt-[2ch] border-2 border-white px-[calc(1.5ch-0.5px)] pb-[1ch] pt-0">
|
||||
<fieldset class="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>
|
||||
<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>
|
||||
<a href="mailto:jet@extremist.software">jet@extremist.software</a>
|
||||
</fieldset>
|
||||
<fieldset class="section-block mt-[2ch] border-2 border-white px-[calc(1.5ch-0.5px)] pb-[1ch] pt-0">
|
||||
<fieldset class="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>
|
||||
<ol>
|
||||
<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://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="http://jet7tetd43snvjx3ng5jrhuwm2yhyp76tjtct5mtofg64apokcgq7fqd.onion" class="inline-flex items-center">.onion</a></li>
|
||||
</ol>
|
||||
</fieldset>
|
||||
`)}
|
||||
</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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { frostedBox } from "~/components/frosted-box";
|
||||
|
||||
export function notFoundPage(outlet: HTMLElement) {
|
||||
outlet.classList.remove("qa-outlet");
|
||||
outlet.innerHTML = `
|
||||
<div class="flex flex-col items-center justify-start">
|
||||
<div class="flex flex-col items-center justify-start px-4">
|
||||
${frostedBox(`
|
||||
<h1 style="color: var(--light-red);">404</h1>
|
||||
<p class="mt-[1ch]">Page not found.</p>
|
||||
|
|
|
|||
183
src/pages/qa.ts
183
src/pages/qa.ts
|
|
@ -1,110 +1,82 @@
|
|||
import {
|
||||
getQuestions,
|
||||
getQuestionStats,
|
||||
submitQuestion,
|
||||
type Question,
|
||||
type QuestionStats,
|
||||
} from "~/lib/api";
|
||||
import { getQuestions, submitQuestion, type Question } from "~/lib/api";
|
||||
import {
|
||||
clearQuestionDraft,
|
||||
formatDateOnly,
|
||||
pickPlaceholder,
|
||||
formatExactTimestamp,
|
||||
formatRelativeTimestamp,
|
||||
readQuestionDraft,
|
||||
writeQuestionDraft,
|
||||
} from "~/lib/qa";
|
||||
import { frostedBox } from "~/components/frosted-box";
|
||||
|
||||
const PLACEHOLDER_QUESTIONS = [
|
||||
"Why call yourself a software extremist?",
|
||||
"What are you building at Noisebridge?",
|
||||
"Why Forgejo over GitHub?",
|
||||
"What is the weirdest thing in your nix-config?",
|
||||
"Why did you write HolyC?",
|
||||
"What do you like about San Francisco hacker culture?",
|
||||
"What is your favorite project you've seen at TIAT?",
|
||||
"What is your favorite project you've seen at Noisebridge?",
|
||||
"What is your favorite hacker conference?",
|
||||
"What is your cat's name?",
|
||||
"What are your favorite programming languages and tools?",
|
||||
"Who are your biggest inspirations?",
|
||||
] as const;
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function formatQuestionTooltip(question: Question): string {
|
||||
const askedExact = formatDateOnly(question.created_at);
|
||||
const answeredExact = formatDateOnly(question.answered_at);
|
||||
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 `
|
||||
<p>Asked ${escapeHtml(askedExact)}</p>
|
||||
<p>Answered ${escapeHtml(answeredExact)}</p>`;
|
||||
}
|
||||
|
||||
function formatRatio(stats: QuestionStats): string {
|
||||
if (stats.asked === 0) return "0%";
|
||||
return `${Math.round((stats.answered / stats.asked) * 100)}%`;
|
||||
<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 = `
|
||||
<section class="qa-item qa-list-item px-[calc(1.5ch-0.5px)] pb-[1ch] pt-0">
|
||||
<p class="qa-list-label">No answers yet</p>
|
||||
<p>...</p>
|
||||
</section>`;
|
||||
<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) => `
|
||||
<section class="qa-item qa-list-item mb-[2ch] px-[calc(1.5ch-0.5px)] pb-[1ch] pt-0" tabindex="0">
|
||||
<div class="qa-item-meta" role="note">
|
||||
${formatQuestionTooltip(q)}
|
||||
</div>
|
||||
<p style="color: var(--light-gray);">${escapeHtml(q.question)}</p>
|
||||
<p class="mt-[1ch]" style="color: var(--light-blue);">${escapeHtml(q.answer)}</p>
|
||||
</section>`,
|
||||
<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) {
|
||||
outlet.classList.add("qa-outlet");
|
||||
const draft = readQuestionDraft();
|
||||
const placeholderQuestion = pickPlaceholder(PLACEHOLDER_QUESTIONS);
|
||||
|
||||
outlet.innerHTML = `
|
||||
<div class="qa-page flex h-full flex-col items-center justify-start">
|
||||
${frostedBox(
|
||||
`
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="flex flex-col items-center justify-start px-4">
|
||||
${frostedBox(`
|
||||
<form id="qa-form" novalidate>
|
||||
<section class="section-block">
|
||||
<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>
|
||||
<div class="qa-input-wrap">
|
||||
<textarea id="qa-input" maxlength="200" rows="3"
|
||||
class="qa-textarea"
|
||||
aria-describedby="qa-status char-count"
|
||||
placeholder="${escapeHtml(placeholderQuestion)}">${escapeHtml(draft)}</textarea>
|
||||
<div class="qa-input-bar">
|
||||
<span id="char-count" class="qa-bar-text">${draft.length}/200</span>
|
||||
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>
|
||||
</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>
|
||||
</fieldset>
|
||||
</form>
|
||||
<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 id="qa-list" class="mt-[2ch]" aria-live="polite">Loading answered questions...</div>
|
||||
`)}
|
||||
</div>`;
|
||||
|
||||
const form = document.getElementById("qa-form") as HTMLFormElement;
|
||||
|
|
@ -113,46 +85,28 @@ export async function qaPage(outlet: HTMLElement) {
|
|||
"qa-submit",
|
||||
) as HTMLButtonElement;
|
||||
const charCount = document.getElementById("char-count") as HTMLSpanElement;
|
||||
const validation = document.getElementById(
|
||||
"qa-validation",
|
||||
) as HTMLParagraphElement;
|
||||
const status = document.getElementById("qa-status") as HTMLParagraphElement;
|
||||
const stats = document.getElementById("qa-stats") as HTMLDivElement;
|
||||
const list = document.getElementById("qa-list") as HTMLDivElement;
|
||||
|
||||
let isSubmitting = false;
|
||||
let hasInteracted = draft.trim().length > 0;
|
||||
let buttonResetTimer: number | null = null;
|
||||
const defaultButtonText = "[SUBMIT]";
|
||||
|
||||
function setStatus(message: string, color: string) {
|
||||
status.textContent = message;
|
||||
status.style.color = color;
|
||||
}
|
||||
|
||||
function setStatsDisplay(nextStats: QuestionStats) {
|
||||
stats.textContent = `Asked ${nextStats.asked} | Answered ${nextStats.answered} | Ratio ${formatRatio(nextStats)}`;
|
||||
}
|
||||
|
||||
function showButtonMessage(message: string, color: string, duration = 1600) {
|
||||
if (buttonResetTimer !== null) window.clearTimeout(buttonResetTimer);
|
||||
submitButton.textContent = message;
|
||||
submitButton.style.color = color;
|
||||
submitButton.classList.add("qa-button-message");
|
||||
buttonResetTimer = window.setTimeout(() => {
|
||||
buttonResetTimer = null;
|
||||
submitButton.textContent = defaultButtonText;
|
||||
submitButton.style.color = "";
|
||||
submitButton.classList.remove("qa-button-message");
|
||||
if (!input.matches(":focus") && input.value.trim().length === 0) {
|
||||
input.removeAttribute("aria-invalid");
|
||||
}
|
||||
}, duration);
|
||||
}
|
||||
|
||||
function updateValidation() {
|
||||
const trimmed = input.value.trim();
|
||||
const remaining = 200 - input.value.length;
|
||||
charCount.textContent = `${input.value.length}/200`;
|
||||
|
||||
if (trimmed.length === 0 && hasInteracted) {
|
||||
validation.textContent = "Question cannot be empty.";
|
||||
validation.style.color = "var(--light-red)";
|
||||
input.setAttribute("aria-invalid", "true");
|
||||
return false;
|
||||
}
|
||||
|
|
@ -160,16 +114,13 @@ export async function qaPage(outlet: HTMLElement) {
|
|||
input.removeAttribute("aria-invalid");
|
||||
|
||||
if (remaining <= 20) {
|
||||
charCount.style.color =
|
||||
remaining === 0
|
||||
? "var(--light-red)"
|
||||
: remaining <= 5
|
||||
? "var(--yellow)"
|
||||
: "var(--dark-gray)";
|
||||
validation.textContent = `${remaining} characters left.`;
|
||||
validation.style.color =
|
||||
remaining <= 5 ? "var(--yellow)" : "var(--dark-gray)";
|
||||
return true;
|
||||
}
|
||||
|
||||
charCount.style.color = "var(--dark-gray)";
|
||||
validation.textContent = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -182,15 +133,14 @@ export async function qaPage(outlet: HTMLElement) {
|
|||
const questions = await getQuestions();
|
||||
renderQuestions(list, questions);
|
||||
} catch {
|
||||
showButtonMessage("[LOAD FAILED]", "var(--light-red)");
|
||||
list.innerHTML = `
|
||||
<section class="qa-item qa-list-item px-[calc(1.5ch-0.5px)] pb-[1ch] pt-0">
|
||||
<p class="qa-list-label" style="color: var(--light-red);">Load failed</p>
|
||||
<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>
|
||||
</section>`;
|
||||
</fieldset>`;
|
||||
list.style.color = "var(--light-red)";
|
||||
|
||||
const retryButton = document.getElementById(
|
||||
|
|
@ -202,15 +152,6 @@ export async function qaPage(outlet: HTMLElement) {
|
|||
}
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const nextStats = await getQuestionStats();
|
||||
setStatsDisplay(nextStats);
|
||||
} catch {
|
||||
stats.textContent = "Asked ? | Answered ? | Ratio ?";
|
||||
}
|
||||
}
|
||||
|
||||
input.addEventListener("input", () => {
|
||||
hasInteracted = true;
|
||||
writeQuestionDraft(input.value);
|
||||
|
|
@ -231,15 +172,13 @@ export async function qaPage(outlet: HTMLElement) {
|
|||
const question = input.value.trim();
|
||||
hasInteracted = true;
|
||||
if (!updateValidation() || !question) {
|
||||
setStatus("", "var(--dark-gray)");
|
||||
showButtonMessage("[EMPTY]", "var(--light-red)");
|
||||
setStatus("Write a question before submitting.", "var(--light-red)");
|
||||
input.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting = true;
|
||||
submitButton.disabled = true;
|
||||
submitButton.textContent = "[SENDING]";
|
||||
setStatus("Submitting...", "var(--light-gray)");
|
||||
|
||||
submitQuestion(question)
|
||||
|
|
@ -247,8 +186,6 @@ export async function qaPage(outlet: HTMLElement) {
|
|||
input.value = "";
|
||||
clearQuestionDraft();
|
||||
hasInteracted = false;
|
||||
submitButton.textContent = defaultButtonText;
|
||||
submitButton.style.color = "";
|
||||
updateValidation();
|
||||
setStatus(
|
||||
"Question submitted! It will appear here once answered.",
|
||||
|
|
@ -257,27 +194,17 @@ export async function qaPage(outlet: HTMLElement) {
|
|||
input.focus();
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to submit question.";
|
||||
if (message.includes("Too many questions")) {
|
||||
showButtonMessage("[RATE LIMIT]", "var(--light-red)");
|
||||
setStatus(message, "var(--light-red)");
|
||||
} else {
|
||||
showButtonMessage("[FAILED]", "var(--light-red)");
|
||||
setStatus("", "var(--dark-gray)");
|
||||
}
|
||||
setStatus(
|
||||
err instanceof Error ? err.message : "Failed to submit question.",
|
||||
"var(--light-red)",
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
isSubmitting = false;
|
||||
submitButton.disabled = false;
|
||||
if (buttonResetTimer === null) {
|
||||
submitButton.textContent = defaultButtonText;
|
||||
submitButton.style.color = "";
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
updateValidation();
|
||||
await loadStats();
|
||||
await loadQuestions();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,17 +7,14 @@ interface Route {
|
|||
pattern: RegExp;
|
||||
keys: string[];
|
||||
handler: PageHandler;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const routes: Route[] = [];
|
||||
let notFoundHandler: PageHandler | null = null;
|
||||
let notFoundTitle = "404 - Jet Pham";
|
||||
|
||||
export function route(path: string, title: string, handler: PageHandler) {
|
||||
export function route(path: string, handler: PageHandler) {
|
||||
if (path === "*") {
|
||||
notFoundHandler = handler;
|
||||
notFoundTitle = title;
|
||||
return;
|
||||
}
|
||||
const keys: string[] = [];
|
||||
|
|
@ -25,13 +22,13 @@ export function route(path: string, title: string, handler: PageHandler) {
|
|||
keys.push(key);
|
||||
return "([^/]+)";
|
||||
});
|
||||
routes.push({ pattern: new RegExp(`^${pattern}$`), keys, handler, title });
|
||||
routes.push({ pattern: new RegExp(`^${pattern}$`), keys, handler });
|
||||
}
|
||||
|
||||
export function navigate(path: string) {
|
||||
history.pushState(null, "", path);
|
||||
window.scrollTo({ top: 0, behavior: "auto" });
|
||||
void render();
|
||||
void render(true);
|
||||
}
|
||||
|
||||
function updateNavState(path: string) {
|
||||
|
|
@ -46,7 +43,7 @@ function updateNavState(path: string) {
|
|||
});
|
||||
}
|
||||
|
||||
async function render() {
|
||||
async function render(focusOutlet = false) {
|
||||
const path = location.pathname;
|
||||
const outlet = document.getElementById("outlet")!;
|
||||
updateNavState(path);
|
||||
|
|
@ -60,7 +57,7 @@ async function render() {
|
|||
});
|
||||
outlet.innerHTML = "";
|
||||
await r.handler(outlet, params);
|
||||
document.title = r.title;
|
||||
if (focusOutlet) outlet.focus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -69,18 +66,17 @@ async function render() {
|
|||
if (notFoundHandler) {
|
||||
await notFoundHandler(outlet, {});
|
||||
}
|
||||
document.title = notFoundTitle;
|
||||
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.hash &&
|
||||
!anchor.hasAttribute("data-native-link") &&
|
||||
!anchor.hasAttribute("download") &&
|
||||
!anchor.hasAttribute("target")
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -35,85 +35,40 @@
|
|||
--white: #ffffff;
|
||||
}
|
||||
|
||||
/* Global BBS-style 80-character width constraint - responsive */
|
||||
html {
|
||||
min-height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background-color: var(--black);
|
||||
color: var(--white);
|
||||
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;
|
||||
font-size: 1.25rem; /* Smaller font size for mobile */
|
||||
white-space: normal;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: var(--light-blue);
|
||||
color: var(--black);
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Desktop font size */
|
||||
@media (min-width: 768px) {
|
||||
body {
|
||||
html {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.page-frame {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
width: min(80ch, 100vw);
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
gap: 2ch;
|
||||
/* Apply CGA theme to body */
|
||||
body {
|
||||
height: 100%;
|
||||
background-color: var(--black);
|
||||
color: var(--white);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#outlet {
|
||||
display: block;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#outlet.qa-outlet {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#outlet:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.site-shell {
|
||||
width: min(100%, 60%);
|
||||
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 */
|
||||
|
|
@ -144,10 +99,10 @@ a[aria-current="page"] {
|
|||
text-decoration: underline;
|
||||
}
|
||||
|
||||
a[aria-current="page"] {
|
||||
color: var(--yellow);
|
||||
background-color: transparent;
|
||||
text-decoration: underline;
|
||||
a[href^="http://"]::after,
|
||||
a[href^="https://"]::after {
|
||||
content: " [EXT]";
|
||||
color: var(--dark-gray);
|
||||
}
|
||||
|
||||
.skip-link {
|
||||
|
|
@ -166,62 +121,12 @@ a[aria-current="page"] {
|
|||
}
|
||||
|
||||
/* Form inputs */
|
||||
.qa-input-wrap {
|
||||
position: relative;
|
||||
padding: 1ch;
|
||||
background-color: rgba(0, 0, 0, 0.18);
|
||||
border: 2px solid var(--white);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1ch;
|
||||
}
|
||||
|
||||
.qa-page {
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.qa-list-scroll {
|
||||
padding-top: 1.5ch;
|
||||
padding-bottom: 1.5ch;
|
||||
scrollbar-gutter: stable;
|
||||
overscroll-behavior: contain;
|
||||
padding-right: 0.5ch;
|
||||
}
|
||||
|
||||
.qa-list-scroll::-webkit-scrollbar {
|
||||
width: 1ch;
|
||||
}
|
||||
|
||||
.qa-list-scroll::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.qa-list-scroll::-webkit-scrollbar-thumb {
|
||||
background-color: var(--dark-gray);
|
||||
border-left: 1px solid transparent;
|
||||
border-right: 1px solid transparent;
|
||||
}
|
||||
|
||||
.qa-list-scroll::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--light-gray);
|
||||
}
|
||||
|
||||
.qa-stats {
|
||||
color: var(--dark-gray);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.qa-textarea {
|
||||
width: 100%;
|
||||
min-height: 2lh;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
caret-color: var(--white);
|
||||
caret-shape: block;
|
||||
background-color: var(--black);
|
||||
border: 2px solid var(--white);
|
||||
color: var(--light-gray);
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
padding: 1ch;
|
||||
resize: none;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
|
|
@ -232,28 +137,14 @@ a[aria-current="page"] {
|
|||
color: var(--dark-gray);
|
||||
}
|
||||
|
||||
.qa-input-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1ch;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.qa-bar-text {
|
||||
color: var(--dark-gray);
|
||||
}
|
||||
|
||||
.qa-button {
|
||||
border: none;
|
||||
padding: 0;
|
||||
padding: 0.25ch 1ch;
|
||||
color: var(--yellow);
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.qa-button:hover {
|
||||
|
|
@ -261,10 +152,6 @@ a[aria-current="page"] {
|
|||
color: var(--black);
|
||||
}
|
||||
|
||||
.qa-button-message:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.qa-button:disabled {
|
||||
color: var(--dark-gray);
|
||||
cursor: wait;
|
||||
|
|
@ -275,46 +162,14 @@ a[aria-current="page"] {
|
|||
color: var(--dark-gray);
|
||||
}
|
||||
|
||||
.qa-meta {
|
||||
margin-top: 0.5ch;
|
||||
.qa-helper {
|
||||
margin-top: 1ch;
|
||||
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-meta {
|
||||
margin-top: 0.5ch;
|
||||
color: var(--dark-gray);
|
||||
}
|
||||
|
||||
.qa-inline-action {
|
||||
|
|
@ -334,23 +189,6 @@ a[aria-current="page"] {
|
|||
color: var(--black);
|
||||
}
|
||||
|
||||
.qa-textarea::selection {
|
||||
background-color: var(--light-blue);
|
||||
color: var(--black);
|
||||
}
|
||||
|
||||
.qa-textarea:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.site-footer-inner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 1ch;
|
||||
color: var(--dark-gray);
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { defineConfig } from "vite";
|
||||
import { execSync } from "node:child_process";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import wasm from "vite-plugin-wasm";
|
||||
import topLevelAwait from "vite-plugin-top-level-await";
|
||||
|
|
@ -7,12 +6,7 @@ import { viteSingleFile } from "vite-plugin-singlefile";
|
|||
import ansi from "./vite-plugin-ansi";
|
||||
import markdown from "./vite-plugin-markdown";
|
||||
|
||||
const commitSha = execSync("git rev-parse HEAD", { encoding: "utf8" }).trim();
|
||||
|
||||
export default defineConfig({
|
||||
define: {
|
||||
__COMMIT_SHA__: JSON.stringify(commitSha),
|
||||
},
|
||||
plugins: [
|
||||
ansi(),
|
||||
markdown(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue