fix: extensive ui improvements
This commit is contained in:
parent
691394445a
commit
6ba64d29a9
17 changed files with 684 additions and 142 deletions
|
|
@ -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()))
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
28
index.html
28
index.html
|
|
@ -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&A]</a>
|
<a href="/qa" data-nav-link>[Q&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
0
public/pgp.txt
Normal file
2
public/ssh.txt
Normal file
2
public/ssh.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
# Jet Pham SSH fingerprints
|
||||||
|
ssh-ed25519 SHA256:Ziw7a2bUA1ew4AFQLB8rk9G3l9I4/eRClf9OJMLMLUA
|
||||||
|
|
@ -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
2
src/global.d.ts
vendored
|
|
@ -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;
|
||||||
|
|
|
||||||
109
src/lib/api.ts
109
src/lib/api.ts
|
|
@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
47
src/lib/site.ts
Normal 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>`;
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
179
src/pages/qa.ts
179
src/pages/qa.ts
|
|
@ -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>
|
||||||
·
|
<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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue