283 lines
9.1 KiB
TypeScript
283 lines
9.1 KiB
TypeScript
import {
|
|
getQuestions,
|
|
getQuestionStats,
|
|
submitQuestion,
|
|
type Question,
|
|
type QuestionStats,
|
|
} from "~/lib/api";
|
|
import {
|
|
clearQuestionDraft,
|
|
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 formatQuestionTooltip(question: Question): string {
|
|
const askedExact = formatDateOnly(question.created_at);
|
|
const answeredExact = formatDateOnly(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)}%`;
|
|
}
|
|
|
|
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>`;
|
|
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>`,
|
|
)
|
|
.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">
|
|
<form id="qa-form" novalidate>
|
|
<section class="section-block">
|
|
<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>
|
|
<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>
|
|
</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>`;
|
|
|
|
const form = document.getElementById("qa-form") as HTMLFormElement;
|
|
const input = document.getElementById("qa-input") as HTMLTextAreaElement;
|
|
const submitButton = document.getElementById(
|
|
"qa-submit",
|
|
) as HTMLButtonElement;
|
|
const charCount = document.getElementById("char-count") as HTMLSpanElement;
|
|
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) {
|
|
input.setAttribute("aria-invalid", "true");
|
|
return false;
|
|
}
|
|
|
|
input.removeAttribute("aria-invalid");
|
|
|
|
if (remaining <= 20) {
|
|
charCount.style.color =
|
|
remaining === 0
|
|
? "var(--light-red)"
|
|
: remaining <= 5
|
|
? "var(--yellow)"
|
|
: "var(--dark-gray)";
|
|
return true;
|
|
}
|
|
|
|
charCount.style.color = "var(--dark-gray)";
|
|
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 {
|
|
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>
|
|
<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>`;
|
|
list.style.color = "var(--light-red)";
|
|
|
|
const retryButton = document.getElementById(
|
|
"qa-retry",
|
|
) as HTMLButtonElement | null;
|
|
retryButton?.addEventListener("click", () => {
|
|
void loadQuestions();
|
|
});
|
|
}
|
|
}
|
|
|
|
async function loadStats() {
|
|
try {
|
|
const nextStats = await getQuestionStats();
|
|
setStatsDisplay(nextStats);
|
|
} catch {
|
|
stats.textContent = "Asked ? | Answered ? | Ratio ?";
|
|
}
|
|
}
|
|
|
|
input.addEventListener("input", () => {
|
|
hasInteracted = true;
|
|
writeQuestionDraft(input.value);
|
|
updateValidation();
|
|
});
|
|
|
|
input.addEventListener("keydown", (e) => {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault();
|
|
form.requestSubmit();
|
|
}
|
|
});
|
|
|
|
form.addEventListener("submit", (e) => {
|
|
e.preventDefault();
|
|
if (isSubmitting) return;
|
|
|
|
const question = input.value.trim();
|
|
hasInteracted = true;
|
|
if (!updateValidation() || !question) {
|
|
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)
|
|
.then(() => {
|
|
input.value = "";
|
|
clearQuestionDraft();
|
|
hasInteracted = false;
|
|
submitButton.textContent = defaultButtonText;
|
|
submitButton.style.color = "";
|
|
updateValidation();
|
|
setStatus(
|
|
"Question submitted! It will appear here once answered.",
|
|
"var(--light-green)",
|
|
);
|
|
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)");
|
|
}
|
|
})
|
|
.finally(() => {
|
|
isSubmitting = false;
|
|
submitButton.disabled = false;
|
|
if (buttonResetTimer === null) {
|
|
submitButton.textContent = defaultButtonText;
|
|
submitButton.style.color = "";
|
|
}
|
|
});
|
|
});
|
|
|
|
updateValidation();
|
|
await loadStats();
|
|
await loadQuestions();
|
|
}
|