feat: migrate site to TanStack Start
This commit is contained in:
parent
056daa6460
commit
1bf7b32040
33 changed files with 8684 additions and 1106 deletions
4
src/client.tsx
Normal file
4
src/client.tsx
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { StartClient } from "@tanstack/react-start/client";
|
||||
import { hydrateRoot } from "react-dom/client";
|
||||
|
||||
hydrateRoot(document, <StartClient />);
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
export function frostedBox(content: string, extraClasses?: string): string {
|
||||
return `
|
||||
<div class="site-shell site-panel-frame relative my-[2ch] overflow-hidden px-[2ch] py-[2ch] ${extraClasses ?? ""}">
|
||||
<div class="site-panel-frost pointer-events-none" aria-hidden="true"></div>
|
||||
<div class="site-panel-border" aria-hidden="true"></div>
|
||||
<div class="site-panel-content h-full min-h-0">
|
||||
${content}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
21
src/components/frosted-box.tsx
Normal file
21
src/components/frosted-box.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type { ReactNode } from "react";
|
||||
|
||||
interface FrostedBoxProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FrostedBox({ children, className = "" }: FrostedBoxProps) {
|
||||
return (
|
||||
<div
|
||||
className={`site-shell site-panel-frame relative my-[2ch] overflow-hidden px-[2ch] py-[2ch] ${className}`}
|
||||
>
|
||||
<div
|
||||
className="site-panel-frost pointer-events-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="site-panel-border" aria-hidden="true" />
|
||||
<div className="site-panel-content h-full min-h-0">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
108
src/components/site-shell.tsx
Normal file
108
src/components/site-shell.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { HeadContent, Link, Outlet, Scripts } from "@tanstack/react-router";
|
||||
import { useEffect } from "react";
|
||||
import { initWebGLBackground } from "~/lib/webgl-background";
|
||||
|
||||
const CLEARNET_HOST = "jetpham.com";
|
||||
const ONION_HOST =
|
||||
"jet7tetd43snvjx3ng5jrhuwm2yhyp76tjtct5mtofg64apokcgq7fqd.onion";
|
||||
const REPO_URL = "https://git.extremist.software/jet/website";
|
||||
|
||||
function getMirrorLink() {
|
||||
if (typeof location !== "undefined" && location.hostname.endsWith(".onion")) {
|
||||
return { href: `https://${CLEARNET_HOST}`, label: "clearnet" };
|
||||
}
|
||||
|
||||
return { href: `http://${ONION_HOST}`, label: ".onion" };
|
||||
}
|
||||
|
||||
function NavLink({ to, children }: { to: string; children: string }) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className="site-nav-link"
|
||||
activeProps={{ "aria-current": "page" }}
|
||||
>
|
||||
<span className="site-nav-marker" aria-hidden="true">
|
||||
>
|
||||
</span>
|
||||
<span>{children}</span>
|
||||
<span className="site-nav-marker" aria-hidden="true">
|
||||
<
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function Footer() {
|
||||
const mirror = getMirrorLink();
|
||||
|
||||
return (
|
||||
<div className="site-shell">
|
||||
<div className="site-panel-frame px-[2ch] py-[1ch]">
|
||||
<div className="site-panel-frost" aria-hidden="true" />
|
||||
<div className="site-panel-border" aria-hidden="true" />
|
||||
<div className="site-panel-content site-footer-inner">
|
||||
<a href={REPO_URL}>Src</a>
|
||||
<span aria-hidden="true">|</span>
|
||||
<a href="/qa/rss.xml">RSS</a>
|
||||
<span aria-hidden="true">|</span>
|
||||
<a href="/pgp.txt">PGP</a>
|
||||
<span aria-hidden="true">|</span>
|
||||
<a href="/ssh.txt">SSH</a>
|
||||
<span aria-hidden="true">|</span>
|
||||
<a href={mirror.href}>{mirror.label}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WebGLBackground() {
|
||||
useEffect(() => {
|
||||
initWebGLBackground();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
id="canvas"
|
||||
className="pointer-events-none fixed inset-0 z-0 h-screen w-screen"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SiteDocument() {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<HeadContent />
|
||||
</head>
|
||||
<body style={{ background: "#000" }}>
|
||||
<a className="skip-link" href="#outlet">
|
||||
Skip to content
|
||||
</a>
|
||||
<WebGLBackground />
|
||||
<div className="page-frame relative z-10">
|
||||
<nav aria-label="Main navigation" className="site-region">
|
||||
<div className="site-shell site-panel-frame px-[2ch] py-[1ch]">
|
||||
<div className="site-panel-frost" aria-hidden="true" />
|
||||
<div className="site-panel-border" aria-hidden="true" />
|
||||
<div className="site-panel-content site-nav-links flex justify-center gap-[2ch]">
|
||||
<NavLink to="/">Home</NavLink>
|
||||
<NavLink to="/qa">Q&A</NavLink>
|
||||
<NavLink to="/colophon">Colophon</NavLink>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<main id="outlet" className="site-region" tabIndex={-1}>
|
||||
<Outlet />
|
||||
</main>
|
||||
<footer className="site-region site-footer">
|
||||
<Footer />
|
||||
</footer>
|
||||
</div>
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
132
src/lib/api.ts
132
src/lib/api.ts
|
|
@ -1,132 +0,0 @@
|
|||
export interface Question {
|
||||
id: number;
|
||||
question: string;
|
||||
answer: string;
|
||||
created_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[]> {
|
||||
if (import.meta.env.DEV) return DEV_QUESTIONS;
|
||||
|
||||
const res = await fetch("/api/questions");
|
||||
if (!res.ok) throw new Error("Failed to fetch questions");
|
||||
return res.json() as Promise<Question[]>;
|
||||
}
|
||||
|
||||
export async function getQuestionStats(): Promise<QuestionStats> {
|
||||
if (import.meta.env.DEV) return DEV_QUESTION_STATS;
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/questions/stats");
|
||||
if (!res.ok) throw new Error("Failed to fetch question stats");
|
||||
return res.json() as Promise<QuestionStats>;
|
||||
} catch {
|
||||
const questions = await getQuestions();
|
||||
return {
|
||||
asked: questions.length,
|
||||
answered: questions.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function submitQuestion(question: string): Promise<void> {
|
||||
const res = await fetch("/api/questions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ question }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (res.status === 429)
|
||||
throw new Error("Too many questions. Please try again later.");
|
||||
throw new Error("Failed to submit question");
|
||||
}
|
||||
}
|
||||
101
src/lib/qa-server.ts
Normal file
101
src/lib/qa-server.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { createServerFn } from "@tanstack/react-start";
|
||||
|
||||
export interface Question {
|
||||
id: number;
|
||||
question: string;
|
||||
answer: string;
|
||||
created_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",
|
||||
},
|
||||
];
|
||||
|
||||
const DEV_QUESTION_STATS: QuestionStats = {
|
||||
asked: 16,
|
||||
answered: DEV_QUESTIONS.length,
|
||||
};
|
||||
|
||||
function apiUrl(path: string) {
|
||||
const base = process.env.QA_API_BASE_URL ?? "http://127.0.0.1:3003";
|
||||
return new URL(path, base).toString();
|
||||
}
|
||||
|
||||
export const getQuestions = createServerFn({ method: "GET" }).handler(
|
||||
async (): Promise<Question[]> => {
|
||||
if (process.env.NODE_ENV === "development") return DEV_QUESTIONS;
|
||||
|
||||
try {
|
||||
const res = await fetch(apiUrl("/api/questions"));
|
||||
if (!res.ok) throw new Error("Failed to fetch questions");
|
||||
return (await res.json()) as Question[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const getQuestionStats = createServerFn({ method: "GET" }).handler(
|
||||
async (): Promise<QuestionStats> => {
|
||||
if (process.env.NODE_ENV === "development") return DEV_QUESTION_STATS;
|
||||
|
||||
try {
|
||||
const res = await fetch(apiUrl("/api/questions/stats"));
|
||||
if (!res.ok) throw new Error("Failed to fetch question stats");
|
||||
return (await res.json()) as QuestionStats;
|
||||
} catch {
|
||||
const questions = await getQuestions.__executeServer({
|
||||
method: "GET",
|
||||
data: undefined,
|
||||
});
|
||||
return {
|
||||
asked: (questions as Question[]).length,
|
||||
answered: (questions as Question[]).length,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const submitQuestion = createServerFn({ method: "POST" })
|
||||
.inputValidator((question: string) => question)
|
||||
.handler(async ({ data: question }): Promise<void> => {
|
||||
const res = await fetch(apiUrl("/api/questions"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ question }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 429) {
|
||||
throw new Error("Too many questions. Please try again later.");
|
||||
}
|
||||
throw new Error("Failed to submit question");
|
||||
}
|
||||
});
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
const CLEARNET_HOST = "jetpham.com";
|
||||
const ONION_HOST =
|
||||
"jet7tetd43snvjx3ng5jrhuwm2yhyp76tjtct5mtofg64apokcgq7fqd.onion";
|
||||
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 mirror = getMirrorLink();
|
||||
|
||||
footer.innerHTML = `
|
||||
<div class="site-panel-frame px-[2ch] py-[1ch]">
|
||||
<div class="site-panel-frost" aria-hidden="true"></div>
|
||||
<div class="site-panel-border" aria-hidden="true"></div>
|
||||
<div class="site-panel-content site-footer-inner">
|
||||
<a href="${REPO_URL}">Src</a>
|
||||
<span aria-hidden="true">|</span>
|
||||
<a href="/qa/rss.xml" data-native-link>RSS</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>`;
|
||||
}
|
||||
15
src/main.ts
15
src/main.ts
|
|
@ -1,15 +0,0 @@
|
|||
import "~/styles/globals.css";
|
||||
import { route, initRouter } from "~/router";
|
||||
import { initWebGLBackground } from "~/lib/webgl-background";
|
||||
import { renderFooter } from "~/lib/site";
|
||||
import { homePage } from "~/pages/home";
|
||||
import { qaPage } from "~/pages/qa";
|
||||
import { notFoundPage } from "~/pages/not-found";
|
||||
|
||||
route("/", "Jet Pham - Home", homePage);
|
||||
route("/qa", "Jet Pham - Q+A", qaPage);
|
||||
route("*", "404 - Jet Pham", notFoundPage);
|
||||
|
||||
renderFooter();
|
||||
initWebGLBackground();
|
||||
initRouter();
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
import Jet from "~/assets/Jet.txt?ansi";
|
||||
import { frostedBox } from "~/components/frosted-box";
|
||||
|
||||
export function homePage(outlet: HTMLElement) {
|
||||
outlet.classList.remove("qa-outlet");
|
||||
outlet.innerHTML = `
|
||||
<div class="flex flex-col items-center justify-start">
|
||||
${frostedBox(`
|
||||
<div class="flex flex-col items-center justify-center gap-[1.25ch] md:gap-[2ch] md:flex-row">
|
||||
<div class="order-1 flex flex-col items-center md:order-2">
|
||||
<h1 class="sr-only">Jet Pham</h1>
|
||||
<div aria-hidden="true" data-emitter-ansi>${Jet}</div>
|
||||
<p class="mt-[2ch]">Software Extremist</p>
|
||||
</div>
|
||||
<div class="order-2 shrink-0 md:order-1">
|
||||
<img
|
||||
data-emitter-image
|
||||
src="/jet.svg"
|
||||
alt="A picture of Jet wearing a beanie in purple and blue lighting"
|
||||
width="250"
|
||||
height="250"
|
||||
class="aspect-square w-full max-w-[220px] object-cover md:h-[263px] md:w-[175px] md:max-w-none"
|
||||
style="background-color: #a80055; color: transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<fieldset class="section-block mt-[2ch] border-2 px-[calc(1.5ch-0.5px)] pb-[1ch] pt-0" style="border-color: var(--white)">
|
||||
<legend class="-mx-[0.5ch] px-[0.5ch]" style="color: var(--white)">Contact</legend>
|
||||
<button type="button" id="copy-email" class="qa-inline-action">jet@extremist.software</button>
|
||||
<span id="copy-email-status" class="qa-meta ml-[1ch]" aria-live="polite"></span>
|
||||
</fieldset>
|
||||
<fieldset class="section-block mt-[2ch] border-2 px-[calc(1.5ch-0.5px)] pb-[1ch] pt-0" style="border-color: var(--white)">
|
||||
<legend class="-mx-[0.5ch] px-[0.5ch]" style="color: var(--white)">Links</legend>
|
||||
<ol>
|
||||
<li><a href="https://git.extremist.software" class="inline-flex items-center">Forgejo</a></li>
|
||||
<li><a href="https://github.com/jetpham" class="inline-flex items-center">GitHub</a></li>
|
||||
<li><a href="https://x.com/exmistsoftware" class="inline-flex items-center">X</a></li>
|
||||
<li><a href="https://bsky.app/profile/extremist.software" class="inline-flex items-center">Bluesky</a></li>
|
||||
</ol>
|
||||
</fieldset>
|
||||
`)}
|
||||
</div>`;
|
||||
|
||||
const copyButton = document.getElementById("copy-email") as HTMLButtonElement;
|
||||
const copyStatus = document.getElementById(
|
||||
"copy-email-status",
|
||||
) as HTMLSpanElement;
|
||||
let resetTimer: number | null = null;
|
||||
|
||||
copyButton.addEventListener("click", () => {
|
||||
void (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,13 +0,0 @@
|
|||
import { frostedBox } from "~/components/frosted-box";
|
||||
|
||||
export function notFoundPage(outlet: HTMLElement) {
|
||||
outlet.classList.remove("qa-outlet");
|
||||
outlet.innerHTML = `
|
||||
<div class="flex flex-col items-center justify-start">
|
||||
${frostedBox(`
|
||||
<h1 style="color: var(--light-red);">404</h1>
|
||||
<p class="mt-[1ch]">Page not found.</p>
|
||||
<p class="mt-[1ch]"><a href="/">[BACK TO HOME]</a></p>
|
||||
`)}
|
||||
</div>`;
|
||||
}
|
||||
283
src/pages/qa.ts
283
src/pages/qa.ts
|
|
@ -1,283 +0,0 @@
|
|||
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();
|
||||
}
|
||||
123
src/routeTree.gen.ts
Normal file
123
src/routeTree.gen.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
/* eslint-disable */
|
||||
|
||||
// @ts-nocheck
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
|
||||
// This file was automatically generated by TanStack Router.
|
||||
// You should NOT make any changes in this file as it will be overwritten.
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as QaRouteImport } from './routes/qa'
|
||||
import { Route as ColophonRouteImport } from './routes/colophon'
|
||||
import { Route as SplatRouteImport } from './routes/$'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
|
||||
const QaRoute = QaRouteImport.update({
|
||||
id: '/qa',
|
||||
path: '/qa',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ColophonRoute = ColophonRouteImport.update({
|
||||
id: '/colophon',
|
||||
path: '/colophon',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const SplatRoute = SplatRouteImport.update({
|
||||
id: '/$',
|
||||
path: '/$',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/$': typeof SplatRoute
|
||||
'/colophon': typeof ColophonRoute
|
||||
'/qa': typeof QaRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/$': typeof SplatRoute
|
||||
'/colophon': typeof ColophonRoute
|
||||
'/qa': typeof QaRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/$': typeof SplatRoute
|
||||
'/colophon': typeof ColophonRoute
|
||||
'/qa': typeof QaRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/$' | '/colophon' | '/qa'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/$' | '/colophon' | '/qa'
|
||||
id: '__root__' | '/' | '/$' | '/colophon' | '/qa'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
SplatRoute: typeof SplatRoute
|
||||
ColophonRoute: typeof ColophonRoute
|
||||
QaRoute: typeof QaRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/qa': {
|
||||
id: '/qa'
|
||||
path: '/qa'
|
||||
fullPath: '/qa'
|
||||
preLoaderRoute: typeof QaRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/colophon': {
|
||||
id: '/colophon'
|
||||
path: '/colophon'
|
||||
fullPath: '/colophon'
|
||||
preLoaderRoute: typeof ColophonRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/$': {
|
||||
id: '/$'
|
||||
path: '/$'
|
||||
fullPath: '/$'
|
||||
preLoaderRoute: typeof SplatRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
SplatRoute: SplatRoute,
|
||||
ColophonRoute: ColophonRoute,
|
||||
QaRoute: QaRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
|
||||
import type { getRouter } from './router.tsx'
|
||||
import type { startInstance } from './start.ts'
|
||||
declare module '@tanstack/react-start' {
|
||||
interface Register {
|
||||
ssr: true
|
||||
router: Awaited<ReturnType<typeof getRouter>>
|
||||
config: Awaited<ReturnType<typeof startInstance.getOptions>>
|
||||
}
|
||||
}
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
type PageHandler = (
|
||||
outlet: HTMLElement,
|
||||
params: Record<string, string>,
|
||||
) => void | Promise<void>;
|
||||
|
||||
interface Route {
|
||||
pattern: RegExp;
|
||||
keys: string[];
|
||||
handler: PageHandler;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const routes: Route[] = [];
|
||||
let notFoundHandler: PageHandler | null = null;
|
||||
let notFoundTitle = "404 - Jet Pham";
|
||||
|
||||
export function route(path: string, title: string, handler: PageHandler) {
|
||||
if (path === "*") {
|
||||
notFoundHandler = handler;
|
||||
notFoundTitle = title;
|
||||
return;
|
||||
}
|
||||
const keys: string[] = [];
|
||||
const pattern = path.replace(/:(\w+)/g, (_, key: string) => {
|
||||
keys.push(key);
|
||||
return "([^/]+)";
|
||||
});
|
||||
routes.push({ pattern: new RegExp(`^${pattern}$`), keys, handler, title });
|
||||
}
|
||||
|
||||
export function navigate(path: string) {
|
||||
history.pushState(null, "", path);
|
||||
window.scrollTo({ top: 0, behavior: "auto" });
|
||||
void 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() {
|
||||
const path = location.pathname;
|
||||
const outlet = document.getElementById("outlet")!;
|
||||
updateNavState(path);
|
||||
|
||||
for (const r of routes) {
|
||||
const match = path.match(r.pattern);
|
||||
if (match) {
|
||||
const params: Record<string, string> = {};
|
||||
r.keys.forEach((key, i) => {
|
||||
params[key] = match[i + 1]!;
|
||||
});
|
||||
outlet.innerHTML = "";
|
||||
await r.handler(outlet, params);
|
||||
document.title = r.title;
|
||||
document.dispatchEvent(new Event("site:render"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
outlet.innerHTML = "";
|
||||
if (notFoundHandler) {
|
||||
await notFoundHandler(outlet, {});
|
||||
}
|
||||
document.title = notFoundTitle;
|
||||
document.dispatchEvent(new Event("site:render"));
|
||||
}
|
||||
|
||||
export function initRouter() {
|
||||
window.addEventListener("popstate", () => void render());
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
const anchor = (e.target as HTMLElement).closest("a");
|
||||
if (
|
||||
anchor?.origin === location.origin &&
|
||||
!anchor.hash &&
|
||||
!anchor.hasAttribute("data-native-link") &&
|
||||
!anchor.hasAttribute("download") &&
|
||||
!anchor.hasAttribute("target")
|
||||
) {
|
||||
e.preventDefault();
|
||||
navigate(anchor.pathname);
|
||||
}
|
||||
});
|
||||
|
||||
void render();
|
||||
}
|
||||
15
src/router.tsx
Normal file
15
src/router.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { createRouter } from "@tanstack/react-router";
|
||||
import { routeTree } from "./routeTree.gen";
|
||||
|
||||
export async function getRouter() {
|
||||
return createRouter({
|
||||
routeTree,
|
||||
scrollRestoration: true,
|
||||
});
|
||||
}
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: Awaited<ReturnType<typeof getRouter>>;
|
||||
}
|
||||
}
|
||||
23
src/routes/$.tsx
Normal file
23
src/routes/$.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { FrostedBox } from "~/components/frosted-box";
|
||||
|
||||
export const Route = createFileRoute("/$")({
|
||||
head: () => ({
|
||||
meta: [{ title: "404 - Jet Pham" }],
|
||||
}),
|
||||
component: NotFoundPage,
|
||||
});
|
||||
|
||||
function NotFoundPage() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-start">
|
||||
<FrostedBox>
|
||||
<h1 style={{ color: "var(--light-red)" }}>404</h1>
|
||||
<p className="mt-[1ch]">Page not found.</p>
|
||||
<p className="mt-[1ch]">
|
||||
<Link to="/">[BACK TO HOME]</Link>
|
||||
</p>
|
||||
</FrostedBox>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
src/routes/__root.tsx
Normal file
114
src/routes/__root.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import "~/styles/globals.css";
|
||||
import { createRootRoute } from "@tanstack/react-router";
|
||||
import { SiteDocument } from "~/components/site-shell";
|
||||
|
||||
const personJsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Person",
|
||||
name: "Jet Pham",
|
||||
givenName: "Jet",
|
||||
familyName: "Pham",
|
||||
description: "Software extremist.",
|
||||
url: "https://jetpham.com",
|
||||
jobTitle: "Software Extremist",
|
||||
hasOccupation: {
|
||||
"@type": "Occupation",
|
||||
name: "Hacker",
|
||||
},
|
||||
email: "jet@extremist.software",
|
||||
image: "https://jetpham.com/jet.svg",
|
||||
alumniOf: {
|
||||
"@type": "CollegeOrUniversity",
|
||||
name: "University of San Francisco",
|
||||
url: "https://www.usfca.edu",
|
||||
},
|
||||
hasCredential: {
|
||||
"@type": "EducationalOccupationalCredential",
|
||||
credentialCategory: "degree",
|
||||
name: "Bachelor of Science in Computer Science",
|
||||
},
|
||||
homeLocation: {
|
||||
"@type": "City",
|
||||
name: "San Francisco, CA",
|
||||
},
|
||||
workLocation: {
|
||||
"@type": "City",
|
||||
name: "San Francisco, CA",
|
||||
},
|
||||
memberOf: {
|
||||
"@type": "Organization",
|
||||
name: "Noisebridge",
|
||||
url: "https://www.noisebridge.net",
|
||||
},
|
||||
affiliation: {
|
||||
"@type": "Organization",
|
||||
name: "Noisebridge",
|
||||
url: "https://www.noisebridge.net",
|
||||
},
|
||||
sameAs: [
|
||||
"https://github.com/jetpham",
|
||||
"https://x.com/exmistsoftware",
|
||||
"https://bsky.app/profile/extremist.software",
|
||||
"https://git.extremist.software",
|
||||
],
|
||||
};
|
||||
|
||||
export const Route = createRootRoute({
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ charSet: "UTF-8" },
|
||||
{ name: "viewport", content: "width=device-width, initial-scale=1.0" },
|
||||
{ name: "theme-color", content: "#000000" },
|
||||
{ name: "apple-mobile-web-app-title", content: "Jet Pham" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Jet Pham's personal website. Software extremist.",
|
||||
},
|
||||
{ property: "og:title", content: "Jet Pham - Software Extremist" },
|
||||
{
|
||||
property: "og:description",
|
||||
content: "Jet Pham's personal website. Software extremist.",
|
||||
},
|
||||
{ property: "og:type", content: "website" },
|
||||
{ property: "og:url", content: "https://jetpham.com/" },
|
||||
{ property: "og:site_name", content: "Jet Pham" },
|
||||
{
|
||||
property: "og:image",
|
||||
content: "https://jetpham.com/web-app-manifest-512x512.png",
|
||||
},
|
||||
{ property: "og:image:width", content: "512" },
|
||||
{ property: "og:image:height", content: "512" },
|
||||
{ property: "og:image:alt", content: "Jet Pham" },
|
||||
{ name: "twitter:card", content: "summary" },
|
||||
{ name: "twitter:title", content: "Jet Pham - Software Extremist" },
|
||||
{
|
||||
name: "twitter:description",
|
||||
content: "Jet Pham's personal website. Software extremist.",
|
||||
},
|
||||
{
|
||||
name: "twitter:image",
|
||||
content: "https://jetpham.com/web-app-manifest-512x512.png",
|
||||
},
|
||||
],
|
||||
links: [
|
||||
{ rel: "canonical", href: "https://jetpham.com/" },
|
||||
{ rel: "manifest", href: "/manifest.json" },
|
||||
{ rel: "icon", href: "/favicon.ico" },
|
||||
{ rel: "apple-touch-icon", href: "/apple-icon.png" },
|
||||
{
|
||||
rel: "preload",
|
||||
href: "/Web437_IBM_VGA_8x16.woff",
|
||||
as: "font",
|
||||
type: "font/woff",
|
||||
crossOrigin: "anonymous",
|
||||
},
|
||||
],
|
||||
scripts: [
|
||||
{
|
||||
type: "application/ld+json",
|
||||
children: JSON.stringify(personJsonLd),
|
||||
},
|
||||
],
|
||||
}),
|
||||
component: SiteDocument,
|
||||
});
|
||||
99
src/routes/colophon.tsx
Normal file
99
src/routes/colophon.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { FrostedBox } from "~/components/frosted-box";
|
||||
|
||||
export const Route = createFileRoute("/colophon")({
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ title: "Jet Pham - Colophon" },
|
||||
{
|
||||
name: "description",
|
||||
content:
|
||||
"A short note on how Jet Pham's personal website is built and served.",
|
||||
},
|
||||
],
|
||||
links: [{ rel: "canonical", href: "https://jetpham.com/colophon" }],
|
||||
}),
|
||||
component: ColophonPage,
|
||||
});
|
||||
|
||||
function ColophonPage() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-start">
|
||||
<FrostedBox>
|
||||
<h1 style={{ color: "var(--yellow)" }}>Colophon</h1>
|
||||
|
||||
<fieldset
|
||||
className="section-block mt-[2ch] border-2 px-[calc(1.5ch-0.5px)] pt-0 pb-[1ch]"
|
||||
style={{ borderColor: "var(--white)" }}
|
||||
>
|
||||
<legend
|
||||
className="-mx-[0.5ch] px-[0.5ch]"
|
||||
style={{ color: "var(--white)" }}
|
||||
>
|
||||
Frontend
|
||||
</legend>
|
||||
<ol>
|
||||
<li>TanStack Start renders the routes and server functions.</li>
|
||||
<li>
|
||||
React owns the page shell, homepage, Q&A, and this page.
|
||||
</li>
|
||||
<li>Tailwind CSS v4 handles utility styling.</li>
|
||||
<li>IBM VGA is the font.</li>
|
||||
</ol>
|
||||
</fieldset>
|
||||
|
||||
<fieldset
|
||||
className="section-block mt-[2ch] border-2 px-[calc(1.5ch-0.5px)] pt-0 pb-[1ch]"
|
||||
style={{ borderColor: "var(--white)" }}
|
||||
>
|
||||
<legend
|
||||
className="-mx-[0.5ch] px-[0.5ch]"
|
||||
style={{ color: "var(--white)" }}
|
||||
>
|
||||
Background
|
||||
</legend>
|
||||
<p>
|
||||
The background is a WebGL2 Conway's Game of Life, but new cells get the average color of it's parents. Page
|
||||
text, links, ANSI art, and images act as emitters that feed color
|
||||
back into the simulation.
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset
|
||||
className="section-block mt-[2ch] border-2 px-[calc(1.5ch-0.5px)] pt-0 pb-[1ch]"
|
||||
style={{ borderColor: "var(--white)" }}
|
||||
>
|
||||
<legend
|
||||
className="-mx-[0.5ch] px-[0.5ch]"
|
||||
style={{ color: "var(--white)" }}
|
||||
>
|
||||
Backend
|
||||
</legend>
|
||||
<ol>
|
||||
<li>Q&A storage lives in SQLite behind a Rust API.</li>
|
||||
<li>New questions send local SMTP notifications.</li>
|
||||
<li>Email replies can become public answers through a webhook.</li>
|
||||
<li>Caddy serves the public edge and proxies the app/API.</li>
|
||||
</ol>
|
||||
</fieldset>
|
||||
|
||||
<fieldset
|
||||
className="section-block mt-[2ch] border-2 px-[calc(1.5ch-0.5px)] pt-0 pb-[1ch]"
|
||||
style={{ borderColor: "var(--white)" }}
|
||||
>
|
||||
<legend
|
||||
className="-mx-[0.5ch] px-[0.5ch]"
|
||||
style={{ color: "var(--white)" }}
|
||||
>
|
||||
Deployment
|
||||
</legend>
|
||||
<p>
|
||||
The project ships as a Nix flake with a reusable NixOS module for
|
||||
the frontend service, Rust Q&A API, Caddy, and onion
|
||||
service.
|
||||
</p>
|
||||
</fieldset>
|
||||
</FrostedBox>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
100
src/routes/index.tsx
Normal file
100
src/routes/index.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import Jet from "~/assets/Jet.txt?ansi";
|
||||
import { FrostedBox } from "~/components/frosted-box";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
head: () => ({
|
||||
meta: [{ title: "Jet Pham - Home" }],
|
||||
}),
|
||||
component: HomePage,
|
||||
});
|
||||
|
||||
function HomePage() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-start">
|
||||
<FrostedBox>
|
||||
<div className="flex flex-col items-center justify-center gap-[1.25ch] md:flex-row md:gap-[2ch]">
|
||||
<div className="order-1 flex flex-col items-center md:order-2">
|
||||
<h1 className="sr-only">Jet Pham</h1>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-emitter-ansi
|
||||
dangerouslySetInnerHTML={{ __html: Jet }}
|
||||
/>
|
||||
<p className="mt-[2ch]">Software Extremist</p>
|
||||
</div>
|
||||
<div className="order-2 shrink-0 md:order-1">
|
||||
<img
|
||||
data-emitter-image
|
||||
src="/jet.svg"
|
||||
alt="A picture of Jet wearing a beanie in purple and blue lighting"
|
||||
width="250"
|
||||
height="250"
|
||||
className="aspect-square w-full max-w-[220px] object-cover md:h-[263px] md:w-[175px] md:max-w-none"
|
||||
style={{ backgroundColor: "#a80055", color: "transparent" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<fieldset
|
||||
className="section-block mt-[2ch] border-2 px-[calc(1.5ch-0.5px)] pt-0 pb-[1ch]"
|
||||
style={{ borderColor: "var(--white)" }}
|
||||
>
|
||||
<legend
|
||||
className="-mx-[0.5ch] px-[0.5ch]"
|
||||
style={{ color: "var(--white)" }}
|
||||
>
|
||||
Contact
|
||||
</legend>
|
||||
<a href="mailto:jet@extremist.software" className="qa-inline-action">
|
||||
jet@extremist.software
|
||||
</a>
|
||||
</fieldset>
|
||||
<fieldset
|
||||
className="section-block mt-[2ch] border-2 px-[calc(1.5ch-0.5px)] pt-0 pb-[1ch]"
|
||||
style={{ borderColor: "var(--white)" }}
|
||||
>
|
||||
<legend
|
||||
className="-mx-[0.5ch] px-[0.5ch]"
|
||||
style={{ color: "var(--white)" }}
|
||||
>
|
||||
Links
|
||||
</legend>
|
||||
<ol>
|
||||
<li>
|
||||
<a
|
||||
href="https://git.extremist.software"
|
||||
className="inline-flex items-center"
|
||||
>
|
||||
Forgejo
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/jetpham"
|
||||
className="inline-flex items-center"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://x.com/exmistsoftware"
|
||||
className="inline-flex items-center"
|
||||
>
|
||||
X
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://bsky.app/profile/extremist.software"
|
||||
className="inline-flex items-center"
|
||||
>
|
||||
Bluesky
|
||||
</a>
|
||||
</li>
|
||||
</ol>
|
||||
</fieldset>
|
||||
</FrostedBox>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
248
src/routes/qa.tsx
Normal file
248
src/routes/qa.tsx
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useEffect, useState, type FormEvent, type KeyboardEvent } from "react";
|
||||
import { FrostedBox } from "~/components/frosted-box";
|
||||
import {
|
||||
getQuestions,
|
||||
getQuestionStats,
|
||||
submitQuestion,
|
||||
type Question,
|
||||
type QuestionStats,
|
||||
} from "~/lib/qa-server";
|
||||
import {
|
||||
clearQuestionDraft,
|
||||
pickPlaceholder,
|
||||
readQuestionDraft,
|
||||
writeQuestionDraft,
|
||||
} from "~/lib/qa";
|
||||
|
||||
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;
|
||||
|
||||
export const Route = createFileRoute("/qa")({
|
||||
head: () => ({
|
||||
meta: [{ title: "Jet Pham - Q+A" }],
|
||||
}),
|
||||
loader: async () => {
|
||||
const [questions, stats] = await Promise.all([
|
||||
getQuestions(),
|
||||
getQuestionStats(),
|
||||
]);
|
||||
return { questions, stats };
|
||||
},
|
||||
component: QaPage,
|
||||
});
|
||||
|
||||
function formatRatio(stats: QuestionStats): string {
|
||||
if (stats.asked === 0) return "0%";
|
||||
return `${Math.round((stats.answered / stats.asked) * 100)}%`;
|
||||
}
|
||||
|
||||
function QuestionList({ questions }: { questions: Question[] }) {
|
||||
if (questions.length === 0) {
|
||||
return (
|
||||
<section className="qa-item qa-list-item px-[calc(1.5ch-0.5px)] pt-0 pb-[1ch]">
|
||||
<p className="qa-list-label">No answers yet</p>
|
||||
<p>...</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return questions.map((question) => (
|
||||
<section
|
||||
key={question.id}
|
||||
className="qa-item qa-list-item mb-[2ch] px-[calc(1.5ch-0.5px)] pt-0 pb-[1ch]"
|
||||
>
|
||||
<p style={{ color: "var(--light-gray)" }}>{question.question}</p>
|
||||
<p className="mt-[1ch]" style={{ color: "var(--light-blue)" }}>
|
||||
{question.answer}
|
||||
</p>
|
||||
</section>
|
||||
));
|
||||
}
|
||||
|
||||
function QaPage() {
|
||||
const loaderData = Route.useLoaderData();
|
||||
const [questions, setQuestions] = useState(loaderData.questions);
|
||||
const [stats, setStats] = useState(loaderData.stats);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [placeholder, setPlaceholder] = useState<string>(
|
||||
PLACEHOLDER_QUESTIONS[0],
|
||||
);
|
||||
const [hasInteracted, setHasInteracted] = useState(false);
|
||||
const [status, setStatus] = useState("");
|
||||
const [statusColor, setStatusColor] = useState("var(--dark-gray)");
|
||||
const [buttonMessage, setButtonMessage] = useState("[SUBMIT]");
|
||||
const [buttonColor, setButtonColor] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const trimmed = draft.trim();
|
||||
const isEmptyInvalid = hasInteracted && trimmed.length === 0;
|
||||
const remaining = 200 - draft.length;
|
||||
const charCountColor =
|
||||
remaining === 0
|
||||
? "var(--light-red)"
|
||||
: remaining <= 5
|
||||
? "var(--yellow)"
|
||||
: remaining <= 20
|
||||
? "var(--dark-gray)"
|
||||
: "var(--dark-gray)";
|
||||
|
||||
useEffect(() => {
|
||||
const savedDraft = readQuestionDraft();
|
||||
setDraft(savedDraft);
|
||||
setHasInteracted(savedDraft.trim().length > 0);
|
||||
setPlaceholder(pickPlaceholder(PLACEHOLDER_QUESTIONS));
|
||||
}, []);
|
||||
|
||||
function showButtonMessage(message: string, color: string, duration = 1600) {
|
||||
setButtonMessage(message);
|
||||
setButtonColor(color);
|
||||
window.setTimeout(() => {
|
||||
setButtonMessage("[SUBMIT]");
|
||||
setButtonColor("");
|
||||
}, duration);
|
||||
}
|
||||
|
||||
function updateDraft(value: string) {
|
||||
setHasInteracted(true);
|
||||
setDraft(value);
|
||||
writeQuestionDraft(value);
|
||||
}
|
||||
|
||||
async function refreshQa() {
|
||||
const [nextQuestions, nextStats] = await Promise.all([
|
||||
getQuestions(),
|
||||
getQuestionStats(),
|
||||
]);
|
||||
setQuestions(nextQuestions);
|
||||
setStats(nextStats);
|
||||
}
|
||||
|
||||
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (isSubmitting) return;
|
||||
|
||||
setHasInteracted(true);
|
||||
if (!trimmed) {
|
||||
setStatus("");
|
||||
setStatusColor("var(--dark-gray)");
|
||||
showButtonMessage("[EMPTY]", "var(--light-red)");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setButtonMessage("[SENDING]");
|
||||
setStatus("Submitting...");
|
||||
setStatusColor("var(--light-gray)");
|
||||
|
||||
try {
|
||||
await submitQuestion({ data: trimmed });
|
||||
setDraft("");
|
||||
clearQuestionDraft();
|
||||
setHasInteracted(false);
|
||||
setButtonMessage("[SUBMIT]");
|
||||
setButtonColor("");
|
||||
setStatus("Question submitted! It will appear here once answered.");
|
||||
setStatusColor("var(--light-green)");
|
||||
await refreshQa();
|
||||
} catch (err) {
|
||||
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);
|
||||
setStatusColor("var(--light-red)");
|
||||
} else {
|
||||
showButtonMessage("[FAILED]", "var(--light-red)");
|
||||
setStatus("");
|
||||
setStatusColor("var(--dark-gray)");
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.form?.requestSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="qa-page flex h-full flex-col items-center justify-start">
|
||||
<FrostedBox className="my-0 flex h-full min-h-0 flex-col">
|
||||
<div className="flex h-full flex-col">
|
||||
<form noValidate onSubmit={onSubmit}>
|
||||
<section className="section-block">
|
||||
<label className="sr-only" htmlFor="qa-input">
|
||||
Question
|
||||
</label>
|
||||
<div className="qa-input-wrap">
|
||||
<textarea
|
||||
id="qa-input"
|
||||
maxLength={200}
|
||||
rows={3}
|
||||
className="qa-textarea"
|
||||
aria-describedby="qa-status char-count"
|
||||
aria-invalid={isEmptyInvalid || undefined}
|
||||
placeholder={placeholder}
|
||||
value={draft}
|
||||
onChange={(event) => updateDraft(event.currentTarget.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
<div className="qa-input-bar">
|
||||
<span
|
||||
id="char-count"
|
||||
className="qa-bar-text"
|
||||
style={{ color: charCountColor }}
|
||||
>
|
||||
{draft.length}/200
|
||||
</span>
|
||||
<button
|
||||
type="submit"
|
||||
className={`qa-button ${buttonMessage !== "[SUBMIT]" ? "qa-button-message" : ""}`}
|
||||
disabled={isSubmitting}
|
||||
style={{ color: buttonColor || undefined }}
|
||||
>
|
||||
{buttonMessage}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
id="qa-status"
|
||||
className="qa-meta"
|
||||
aria-live="polite"
|
||||
style={{ color: statusColor }}
|
||||
>
|
||||
{status}
|
||||
</p>
|
||||
<div className="qa-stats mt-[1ch]" aria-live="polite">
|
||||
Asked {stats.asked} | Answered {stats.answered} | Ratio{" "}
|
||||
{formatRatio(stats)}
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
<div
|
||||
className="qa-list-scroll mt-[2ch] min-h-0 flex-1 overflow-y-auto pr-[1ch]"
|
||||
aria-live="polite"
|
||||
>
|
||||
<QuestionList questions={questions} />
|
||||
</div>
|
||||
</div>
|
||||
</FrostedBox>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
src/start.ts
Normal file
5
src/start.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { createStart } from "@tanstack/react-start";
|
||||
|
||||
export const startInstance = createStart(() => ({
|
||||
defaultSsr: true,
|
||||
}));
|
||||
Loading…
Add table
Add a link
Reference in a new issue