fix: extensive ui improvements

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

View file

@ -1,82 +1,110 @@
import { getQuestions, submitQuestion, type Question } from "~/lib/api";
import {
getQuestions,
getQuestionStats,
submitQuestion,
type Question,
type QuestionStats,
} from "~/lib/api";
import {
clearQuestionDraft,
formatExactTimestamp,
formatRelativeTimestamp,
formatDateOnly,
pickPlaceholder,
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 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);
function formatQuestionTooltip(question: Question): string {
const askedExact = formatDateOnly(question.created_at);
const answeredExact = formatDateOnly(question.answered_at);
return `
<span title="${escapeHtml(askedExact)}">Asked ${escapeHtml(asked)}</span>
&middot;
<span title="${escapeHtml(answeredExact)}">Answered ${escapeHtml(answered)}</span>`;
<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)}%`;
}
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>`;
<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>`;
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>
<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-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>`,
</section>`,
)
.join("");
}
export async function qaPage(outlet: HTMLElement) {
outlet.classList.add("qa-outlet");
const draft = readQuestionDraft();
const placeholderQuestion = pickPlaceholder(PLACEHOLDER_QUESTIONS);
outlet.innerHTML = `
<div class="flex flex-col items-center justify-start px-4">
${frostedBox(`
<div class="qa-page flex h-full flex-col items-center justify-start">
${frostedBox(
`
<div class="flex h-full flex-col">
<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>
<section class="section-block">
<label class="sr-only" for="qa-input">Question</label>
<textarea id="qa-input" maxlength="200" rows="3"
class="qa-textarea"
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 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" aria-hidden="true">
<span id="char-count" class="qa-bar-text">${draft.length}/200</span>
<button id="qa-submit" type="submit" class="qa-button">[SUBMIT]</button>
</div>
</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>
</fieldset>
<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>
<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>`;
const form = document.getElementById("qa-form") as HTMLFormElement;
@ -85,28 +113,46 @@ 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;
}
@ -114,13 +160,16 @@ export async function qaPage(outlet: HTMLElement) {
input.removeAttribute("aria-invalid");
if (remaining <= 20) {
validation.textContent = `${remaining} characters left.`;
validation.style.color =
remaining <= 5 ? "var(--yellow)" : "var(--dark-gray)";
charCount.style.color =
remaining === 0
? "var(--light-red)"
: remaining <= 5
? "var(--yellow)"
: "var(--dark-gray)";
return true;
}
validation.textContent = "";
charCount.style.color = "var(--dark-gray)";
return true;
}
@ -133,14 +182,15 @@ export async function qaPage(outlet: HTMLElement) {
const questions = await getQuestions();
renderQuestions(list, questions);
} catch {
showButtonMessage("[LOAD FAILED]", "var(--light-red)");
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>
<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>
<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>`;
</section>`;
list.style.color = "var(--light-red)";
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", () => {
hasInteracted = true;
writeQuestionDraft(input.value);
@ -172,13 +231,15 @@ export async function qaPage(outlet: HTMLElement) {
const question = input.value.trim();
hasInteracted = true;
if (!updateValidation() || !question) {
setStatus("Write a question before submitting.", "var(--light-red)");
setStatus("", "var(--dark-gray)");
showButtonMessage("[EMPTY]", "var(--light-red)");
input.focus();
return;
}
isSubmitting = true;
submitButton.disabled = true;
submitButton.textContent = "[SENDING]";
setStatus("Submitting...", "var(--light-gray)");
submitQuestion(question)
@ -186,6 +247,8 @@ 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.",
@ -194,17 +257,27 @@ export async function qaPage(outlet: HTMLElement) {
input.focus();
})
.catch((err: unknown) => {
setStatus(
err instanceof Error ? err.message : "Failed to submit question.",
"var(--light-red)",
);
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)");
}
})
.finally(() => {
isSubmitting = false;
submitButton.disabled = false;
if (buttonResetTimer === null) {
submitButton.textContent = defaultButtonText;
submitButton.style.color = "";
}
});
});
updateValidation();
await loadStats();
await loadQuestions();
}