feat: add fixes and more support for wierd api bugs

This commit is contained in:
Jet 2026-03-26 01:05:11 -07:00
parent 1149597139
commit 691394445a
No known key found for this signature in database
6 changed files with 686 additions and 242 deletions

62
src/lib/qa.ts Normal file
View file

@ -0,0 +1,62 @@
const DRAFT_KEY = "qa-draft";
const relativeTime = new Intl.RelativeTimeFormat(undefined, {
numeric: "auto",
});
function getValidDate(dateString: string): Date | null {
const date = new Date(dateString);
return Number.isNaN(date.getTime()) ? null : date;
}
export function readQuestionDraft(): string {
try {
return localStorage.getItem(DRAFT_KEY) ?? "";
} catch {
return "";
}
}
export function writeQuestionDraft(value: string) {
try {
localStorage.setItem(DRAFT_KEY, value);
} catch {
// Ignore storage failures.
}
}
export function clearQuestionDraft() {
try {
localStorage.removeItem(DRAFT_KEY);
} catch {
// Ignore storage failures.
}
}
export function formatExactTimestamp(dateString: string): string {
const date = getValidDate(dateString);
if (!date) return dateString;
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(date);
}
export function formatRelativeTimestamp(dateString: string): string {
const date = getValidDate(dateString);
if (!date) return dateString;
const diffMs = date.getTime() - Date.now();
const diffMinutes = Math.round(diffMs / 60000);
const diffHours = Math.round(diffMs / 3600000);
const diffDays = Math.round(diffMs / 86400000);
if (Math.abs(diffMinutes) < 60)
return relativeTime.format(diffMinutes, "minute");
if (Math.abs(diffHours) < 24) return relativeTime.format(diffHours, "hour");
if (Math.abs(diffDays) < 30) return relativeTime.format(diffDays, "day");
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }).format(
date,
);
}

View file

@ -1,4 +1,11 @@
import { getQuestions, submitQuestion } from "~/lib/api";
import { getQuestions, submitQuestion, type Question } from "~/lib/api";
import {
clearQuestionDraft,
formatExactTimestamp,
formatRelativeTimestamp,
readQuestionDraft,
writeQuestionDraft,
} from "~/lib/qa";
import { frostedBox } from "~/components/frosted-box";
function escapeHtml(str: string): string {
@ -7,35 +14,148 @@ function escapeHtml(str: string): string {
return div.innerHTML;
}
function formatQuestionDates(question: Question): string {
const asked = formatRelativeTimestamp(question.created_at);
const answered = formatRelativeTimestamp(question.answered_at);
const askedExact = formatExactTimestamp(question.created_at);
const answeredExact = formatExactTimestamp(question.answered_at);
return `
<span title="${escapeHtml(askedExact)}">Asked ${escapeHtml(asked)}</span>
&middot;
<span title="${escapeHtml(answeredExact)}">Answered ${escapeHtml(answered)}</span>`;
}
function renderQuestions(list: HTMLElement, questions: Question[]) {
if (questions.length === 0) {
list.innerHTML = `
<fieldset class="border-2 border-white px-[calc(1.5ch-0.5px)] pb-[1ch] pt-0">
<legend class="-mx-[0.5ch] px-[0.5ch]" style="color: var(--dark-gray);">No answers yet</legend>
<p>No questions have been answered yet.</p>
<p class="qa-meta">Ask something above and check back once a reply is posted.</p>
</fieldset>`;
return;
}
list.innerHTML = questions
.map(
(q) => `
<fieldset class="border-2 border-white px-[calc(1.5ch-0.5px)] pb-[1ch] pt-0 mb-[2ch]">
<legend class="-mx-[0.5ch] px-[0.5ch]" style="color: var(--dark-gray);">#${String(q.id)}</legend>
<p style="color: var(--light-cyan);">${escapeHtml(q.question)}</p>
<p class="mt-[1ch]" style="color: var(--light-green);">${escapeHtml(q.answer)}</p>
<p class="qa-meta">${formatQuestionDates(q)}</p>
</fieldset>`,
)
.join("");
}
export async function qaPage(outlet: HTMLElement) {
const draft = readQuestionDraft();
outlet.innerHTML = `
<div class="flex flex-col items-center justify-start px-4">
${frostedBox(`
<form id="qa-form">
<form id="qa-form" novalidate>
<fieldset class="border-2 border-white px-[calc(1.5ch-0.5px)] pb-[1ch] pt-[1ch]">
<legend class="-mx-[0.5ch] px-[0.5ch] text-white">Ask a Question</legend>
<label class="sr-only" for="qa-input">Question</label>
<textarea id="qa-input" maxlength="200" rows="3"
class="qa-textarea"
placeholder="Type your question..."></textarea>
<div class="flex justify-between mt-[1ch]">
<span id="char-count" style="color: var(--dark-gray);">0/200</span>
<button type="submit" class="qa-button">[SUBMIT]</button>
aria-describedby="qa-helper qa-validation char-count"
placeholder="Type your question...">${escapeHtml(draft)}</textarea>
<p id="qa-helper" class="qa-helper">Press Enter to send. Press Shift+Enter for a new line.</p>
<div class="mt-[0.5ch] flex justify-between gap-[1ch]">
<p id="qa-validation" class="qa-meta" aria-live="polite"></p>
<span id="char-count" class="qa-meta">${draft.length}/200</span>
</div>
<div class="mt-[1ch] flex justify-between items-center gap-[1ch]">
<p id="qa-status" class="qa-meta" aria-live="polite"></p>
<button id="qa-submit" type="submit" class="qa-button">[SUBMIT]</button>
</div>
<p id="qa-status" class="mt-[0.5ch]" aria-live="polite"></p>
</fieldset>
</form>
<div id="qa-list" class="mt-[2ch]">Loading...</div>
<div id="qa-list" class="mt-[2ch]" aria-live="polite">Loading answered questions...</div>
`)}
</div>`;
const form = document.getElementById("qa-form") as HTMLFormElement;
const input = document.getElementById("qa-input") as HTMLTextAreaElement;
const charCount = document.getElementById("char-count")!;
const status = document.getElementById("qa-status")!;
const list = document.getElementById("qa-list")!;
const submitButton = document.getElementById(
"qa-submit",
) as HTMLButtonElement;
const charCount = document.getElementById("char-count") as HTMLSpanElement;
const validation = document.getElementById(
"qa-validation",
) as HTMLParagraphElement;
const status = document.getElementById("qa-status") as HTMLParagraphElement;
const list = document.getElementById("qa-list") as HTMLDivElement;
let isSubmitting = false;
let hasInteracted = draft.trim().length > 0;
function setStatus(message: string, color: string) {
status.textContent = message;
status.style.color = color;
}
function updateValidation() {
const trimmed = input.value.trim();
const remaining = 200 - input.value.length;
charCount.textContent = `${input.value.length}/200`;
if (trimmed.length === 0 && hasInteracted) {
validation.textContent = "Question cannot be empty.";
validation.style.color = "var(--light-red)";
input.setAttribute("aria-invalid", "true");
return false;
}
input.removeAttribute("aria-invalid");
if (remaining <= 20) {
validation.textContent = `${remaining} characters left.`;
validation.style.color =
remaining <= 5 ? "var(--yellow)" : "var(--dark-gray)";
return true;
}
validation.textContent = "";
return true;
}
async function loadQuestions() {
list.textContent = "Loading answered questions...";
list.style.color = "var(--light-gray)";
list.style.textAlign = "left";
try {
const questions = await getQuestions();
renderQuestions(list, questions);
} catch {
list.innerHTML = `
<fieldset class="border-2 border-white px-[calc(1.5ch-0.5px)] pb-[1ch] pt-0">
<legend class="-mx-[0.5ch] px-[0.5ch]" style="color: var(--light-red);">Load failed</legend>
<p>Failed to load answered questions.</p>
<p class="qa-meta">
<button type="button" id="qa-retry" class="qa-inline-action">Retry loading questions</button>
</p>
</fieldset>`;
list.style.color = "var(--light-red)";
const retryButton = document.getElementById(
"qa-retry",
) as HTMLButtonElement | null;
retryButton?.addEventListener("click", () => {
void loadQuestions();
});
}
}
input.addEventListener("input", () => {
charCount.textContent = `${input.value.length}/200`;
hasInteracted = true;
writeQuestionDraft(input.value);
updateValidation();
});
input.addEventListener("keydown", (e) => {
@ -47,50 +167,44 @@ export async function qaPage(outlet: HTMLElement) {
form.addEventListener("submit", (e) => {
e.preventDefault();
const question = input.value.trim();
if (!question) return;
if (isSubmitting) return;
status.textContent = "Submitting...";
status.style.color = "var(--light-gray)";
const question = input.value.trim();
hasInteracted = true;
if (!updateValidation() || !question) {
setStatus("Write a question before submitting.", "var(--light-red)");
input.focus();
return;
}
isSubmitting = true;
submitButton.disabled = true;
setStatus("Submitting...", "var(--light-gray)");
submitQuestion(question)
.then(() => {
input.value = "";
charCount.textContent = "0/200";
status.textContent =
"Question submitted! It will appear here once answered.";
status.style.color = "var(--light-green)";
clearQuestionDraft();
hasInteracted = false;
updateValidation();
setStatus(
"Question submitted! It will appear here once answered.",
"var(--light-green)",
);
input.focus();
})
.catch((err: unknown) => {
status.textContent =
err instanceof Error ? err.message : "Failed to submit question.";
status.style.color = "var(--light-red)";
setStatus(
err instanceof Error ? err.message : "Failed to submit question.",
"var(--light-red)",
);
})
.finally(() => {
isSubmitting = false;
submitButton.disabled = false;
});
});
try {
const questions = await getQuestions();
if (questions.length === 0) {
list.textContent = "No questions answered yet.";
list.style.color = "var(--dark-gray)";
} else {
list.innerHTML = questions
.map(
(q) => `
<fieldset class="border-2 border-white px-[calc(1.5ch-0.5px)] pb-[1ch] pt-0 mb-[2ch]">
<legend class="-mx-[0.5ch] px-[0.5ch]" style="color: var(--dark-gray);">#${String(q.id)}</legend>
<p style="color: var(--light-cyan);">${escapeHtml(q.question)}</p>
<p class="mt-[1ch]" style="color: var(--light-green);">${escapeHtml(q.answer)}</p>
<p class="mt-[0.5ch]" style="color: var(--dark-gray);">
Asked ${q.created_at} · Answered ${q.answered_at}
</p>
</fieldset>`,
)
.join("");
}
} catch {
list.textContent = "Failed to load questions.";
list.style.color = "var(--light-red)";
list.style.textAlign = "center";
}
updateValidation();
await loadQuestions();
}

View file

@ -27,12 +27,26 @@ export function route(path: string, handler: PageHandler) {
export function navigate(path: string) {
history.pushState(null, "", path);
void render();
window.scrollTo({ top: 0, behavior: "auto" });
void render(true);
}
async function render() {
function updateNavState(path: string) {
const navLinks =
document.querySelectorAll<HTMLAnchorElement>("[data-nav-link]");
navLinks.forEach((link) => {
if (link.pathname === path) {
link.setAttribute("aria-current", "page");
} else {
link.removeAttribute("aria-current");
}
});
}
async function render(focusOutlet = false) {
const path = location.pathname;
const outlet = document.getElementById("outlet")!;
updateNavState(path);
for (const r of routes) {
const match = path.match(r.pattern);
@ -43,6 +57,7 @@ async function render() {
});
outlet.innerHTML = "";
await r.handler(outlet, params);
if (focusOutlet) outlet.focus();
return;
}
}
@ -51,14 +66,20 @@ async function render() {
if (notFoundHandler) {
await notFoundHandler(outlet, {});
}
if (focusOutlet) outlet.focus();
}
export function initRouter() {
window.addEventListener("popstate", () => void render());
window.addEventListener("popstate", () => void render(true));
document.addEventListener("click", (e) => {
const anchor = (e.target as HTMLElement).closest("a");
if (anchor?.origin === location.origin && !anchor.hasAttribute("download")) {
if (
anchor?.origin === location.origin &&
!anchor.hash &&
!anchor.hasAttribute("download") &&
!anchor.hasAttribute("target")
) {
e.preventDefault();
navigate(anchor.pathname);
}

View file

@ -9,144 +9,232 @@
}
:root {
--font-sans: "IBM VGA", ui-monospace, "SF Mono", "Monaco", "Inconsolata", "Roboto Mono", "Source Code Pro", monospace;
--font-mono: "IBM VGA", ui-monospace, "SF Mono", "Monaco", "Inconsolata", "Roboto Mono", "Source Code Pro", monospace;
--font-sans:
"IBM VGA", ui-monospace, "SF Mono", "Monaco", "Inconsolata", "Roboto Mono",
"Source Code Pro", monospace;
--font-mono:
"IBM VGA", ui-monospace, "SF Mono", "Monaco", "Inconsolata", "Roboto Mono",
"Source Code Pro", monospace;
/* 16-color palette */
--black: #000000;
--blue: #0000AA;
--green: #00AA00;
--cyan: #00AAAA;
--red: #AA0000;
--magenta: #AA00AA;
--brown: #AA5500;
--light-gray: #AAAAAA;
--blue: #0000aa;
--green: #00aa00;
--cyan: #00aaaa;
--red: #aa0000;
--magenta: #aa00aa;
--brown: #aa5500;
--light-gray: #aaaaaa;
--dark-gray: #555555;
--light-blue: #5555FF;
--light-green: #55FF55;
--light-cyan: #55FFFF;
--light-red: #FF5555;
--light-magenta: #FF55FF;
--yellow: #FFFF55;
--white: #FFFFFF;
--light-blue: #5555ff;
--light-green: #55ff55;
--light-cyan: #55ffff;
--light-red: #ff5555;
--light-magenta: #ff55ff;
--yellow: #ffff55;
--white: #ffffff;
}
/* Global BBS-style 80-character width constraint - responsive */
/* Global BBS-style 80-character width constraint - responsive */
html {
height: 100%;
width: min(
80ch,
100vw
); /* 80 characters wide on desktop, full width on mobile */
padding: 1rem;
margin: 0 auto;
font-family: "IBM VGA", monospace;
font-size: 1.25rem; /* Smaller font size for mobile */
white-space: normal;
line-height: 1;
box-sizing: border-box;
}
/* Desktop font size */
@media (min-width: 768px) {
html {
height: 100%;
width: min(80ch, 100vw); /* 80 characters wide on desktop, full width on mobile */
padding: 1rem;
margin: 0 auto;
font-family: "IBM VGA", monospace;
font-size: 1.25rem; /* Smaller font size for mobile */
white-space: normal;
line-height: 1;
box-sizing: border-box;
font-size: 1.5rem;
}
/* Desktop font size */
@media (min-width: 768px) {
html {
font-size: 1.5rem;
}
}
/* Apply CGA theme to body */
body {
height: 100%;
background-color: var(--black);
color: var(--white);
margin: 0;
padding: 0;
}
/* Global focus ring styles for all tabbable elements */
button:focus,
a:focus,
input:focus,
textarea:focus,
select:focus,
[tabindex]:focus,
[contenteditable]:focus {
outline: 2px solid white;
outline-offset: -2px;
border-radius: 0;
}
/* Link styles - blue without underline */
a {
color: var(--light-blue);
text-decoration: none;
}
a:hover {
background-color: var(--light-blue);
color: var(--black);
}
/* Form inputs */
.qa-textarea {
width: 100%;
background-color: var(--black);
border: 2px solid var(--white);
color: var(--light-gray);
padding: 1ch;
resize: none;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
/* Apply CGA theme to body */
body {
height: 100%;
background-color: var(--black);
color: var(--white);
margin: 0;
padding: 0;
}
.qa-button {
border: none;
padding: 0.25ch 1ch;
color: var(--yellow);
background: transparent;
font-family: inherit;
font-size: inherit;
cursor: pointer;
}
#outlet {
display: block;
}
.qa-button:hover {
background-color: var(--yellow);
color: var(--black);
}
/* Global focus ring styles for all tabbable elements */
button:focus,
a:focus,
input:focus,
textarea:focus,
select:focus,
[tabindex]:focus,
[contenteditable]:focus {
outline: 2px solid white;
outline-offset: -2px;
border-radius: 0;
}
/* Project markdown content */
.project-content h2 {
color: var(--light-cyan);
margin-top: 2ch;
margin-bottom: 1ch;
}
/* Link styles - blue without underline */
a {
color: var(--light-blue);
text-decoration: none;
text-underline-offset: 0.2ch;
}
.project-content h3 {
color: var(--light-green);
margin-top: 1.5ch;
margin-bottom: 0.5ch;
}
a:hover,
a:focus-visible,
a[aria-current="page"] {
background-color: var(--light-blue);
color: var(--black);
text-decoration: underline;
}
.project-content p {
margin-bottom: 1ch;
}
a[href^="http://"]::after,
a[href^="https://"]::after {
content: " [EXT]";
color: var(--dark-gray);
}
.project-content ul,
.project-content ol {
margin-left: 2ch;
margin-bottom: 1ch;
}
.skip-link {
position: absolute;
left: 1rem;
top: 0;
transform: translateY(-150%);
padding: 0.5ch 1ch;
background: var(--yellow);
color: var(--black);
z-index: 20;
}
.project-content code {
color: var(--yellow);
}
.skip-link:focus {
transform: translateY(1rem);
}
.project-content pre {
border: 2px solid var(--dark-gray);
padding: 1ch;
overflow-x: auto;
margin-bottom: 1ch;
}
/* Form inputs */
.qa-textarea {
width: 100%;
background-color: var(--black);
border: 2px solid var(--white);
color: var(--light-gray);
padding: 1ch;
resize: none;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
.project-content a {
color: var(--light-blue);
}
.qa-textarea::placeholder {
color: var(--dark-gray);
}
.qa-button {
border: none;
padding: 0.25ch 1ch;
color: var(--yellow);
background: transparent;
font-family: inherit;
font-size: inherit;
cursor: pointer;
}
.qa-button:hover {
background-color: var(--yellow);
color: var(--black);
}
.qa-button:disabled {
color: var(--dark-gray);
cursor: wait;
}
.qa-button:disabled:hover {
background: transparent;
color: var(--dark-gray);
}
.qa-helper {
margin-top: 1ch;
color: var(--dark-gray);
}
.qa-meta {
margin-top: 0.5ch;
color: var(--dark-gray);
}
.qa-inline-action {
border: none;
background: transparent;
color: var(--light-blue);
padding: 0;
font: inherit;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 0.2ch;
}
.qa-inline-action:hover,
.qa-inline-action:focus-visible {
background-color: var(--light-blue);
color: var(--black);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Project markdown content */
.project-content h2 {
color: var(--light-cyan);
margin-top: 2ch;
margin-bottom: 1ch;
}
.project-content h3 {
color: var(--light-green);
margin-top: 1.5ch;
margin-bottom: 0.5ch;
}
.project-content p {
margin-bottom: 1ch;
}
.project-content ul,
.project-content ol {
margin-left: 2ch;
margin-bottom: 1ch;
}
.project-content code {
color: var(--yellow);
}
.project-content pre {
border: 2px solid var(--dark-gray);
padding: 1ch;
overflow-x: auto;
margin-bottom: 1ch;
}
.project-content a {
color: var(--light-blue);
}