feat: migrate site to TanStack Start
This commit is contained in:
parent
056daa6460
commit
1bf7b32040
33 changed files with 8684 additions and 1106 deletions
|
|
@ -5,7 +5,7 @@ export default tseslint.config(
|
|||
ignores: ["dist"],
|
||||
},
|
||||
{
|
||||
files: ["**/*.ts"],
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
extends: [
|
||||
...tseslint.configs.recommended,
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
|
|
|
|||
11
flake.nix
11
flake.nix
|
|
@ -22,6 +22,7 @@
|
|||
./package-lock.json
|
||||
./package.json
|
||||
./public
|
||||
./server.mjs
|
||||
./src
|
||||
./tsconfig.json
|
||||
./vite-plugin-ansi.ts
|
||||
|
|
@ -33,14 +34,18 @@
|
|||
pname = "jet-website";
|
||||
version = "0.1.0";
|
||||
src = websiteSrc;
|
||||
npmDepsHash = "sha256-UDz4tXNvEa8uiDDGg16K9JbNeQZR3BsVNKtuOgcyurQ=";
|
||||
npmDepsHash = "sha256-tcWPiPTOfCEKBBt/ZilAnFcfWKD3FkWUM49vLqw41f0=";
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
mkdir -p $out
|
||||
cp -r dist/* $out/
|
||||
mkdir -p $out/share/jet-website $out/bin
|
||||
cp -r dist node_modules package.json server.mjs $out/share/jet-website/
|
||||
makeWrapper ${pkgs.nodejs}/bin/node $out/bin/jet-website \
|
||||
--add-flags $out/share/jet-website/server.mjs
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
nativeBuildInputs = [ pkgs.makeWrapper ];
|
||||
};
|
||||
qa-api = pkgs.rustPlatform.buildRustPackage {
|
||||
pname = "jetpham-qa-api";
|
||||
|
|
|
|||
87
index.html
87
index.html
|
|
@ -46,92 +46,9 @@
|
|||
name="twitter:image"
|
||||
content="https://jetpham.com/web-app-manifest-512x512.png"
|
||||
/>
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@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"
|
||||
]
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body style="background: #000">
|
||||
<a class="skip-link" href="#outlet">Skip to content</a>
|
||||
<canvas
|
||||
id="canvas"
|
||||
class="pointer-events-none fixed inset-0 z-0 h-screen w-screen"
|
||||
aria-hidden="true"
|
||||
></canvas>
|
||||
<div class="page-frame relative z-10">
|
||||
<nav aria-label="Main navigation" class="site-region">
|
||||
<div class="site-shell 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-nav-links flex justify-center gap-[2ch]"
|
||||
>
|
||||
<a href="/" data-nav-link class="site-nav-link"
|
||||
><span class="site-nav-marker" aria-hidden="true">></span
|
||||
><span>Home</span
|
||||
><span class="site-nav-marker" aria-hidden="true"><</span></a
|
||||
>
|
||||
<a href="/qa" data-nav-link class="site-nav-link"
|
||||
><span class="site-nav-marker" aria-hidden="true">></span
|
||||
><span>Q&A</span
|
||||
><span class="site-nav-marker" aria-hidden="true"><</span></a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<main id="outlet" class="site-region" tabindex="-1"></main>
|
||||
<footer class="site-region site-footer">
|
||||
<div id="site-footer" class="site-shell"></div>
|
||||
</footer>
|
||||
</div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/client.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
40
module.nix
40
module.nix
|
|
@ -10,6 +10,7 @@ let
|
|||
cfg = config.services.jetpham-website;
|
||||
package = cfg.package;
|
||||
qaApi = cfg.apiPackage;
|
||||
websiteListen = "${cfg.websiteListenAddress}:${toString cfg.websiteListenPort}";
|
||||
apiListen = "${cfg.apiListenAddress}:${toString cfg.apiListenPort}";
|
||||
usingDefaultWebhookSecret = cfg.webhookSecretFile == null;
|
||||
webhookSecretPath =
|
||||
|
|
@ -45,9 +46,7 @@ let
|
|||
}
|
||||
|
||||
handle {
|
||||
root * ${package}
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
reverse_proxy ${websiteListen}
|
||||
}
|
||||
|
||||
${cfg.caddy.extraConfig}
|
||||
|
|
@ -64,6 +63,18 @@ in
|
|||
description = "Static site package served by Caddy.";
|
||||
};
|
||||
|
||||
websiteListenAddress = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "127.0.0.1";
|
||||
description = "Address for the local TanStack Start frontend listener.";
|
||||
};
|
||||
|
||||
websiteListenPort = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 3002;
|
||||
description = "Port for the local TanStack Start frontend listener.";
|
||||
};
|
||||
|
||||
apiPackage = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
default = self.packages.${pkgs.system}.qa-api;
|
||||
|
|
@ -203,6 +214,29 @@ in
|
|||
|
||||
services.caddy.enable = cfg.caddy.enable;
|
||||
|
||||
systemd.services.jetpham-website = {
|
||||
description = "Jet Pham website frontend";
|
||||
after = [ "network-online.target" ];
|
||||
wants = [ "network-online.target" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
environment = {
|
||||
WEBSITE_LISTEN_ADDRESS = cfg.websiteListenAddress;
|
||||
WEBSITE_LISTEN_PORT = toString cfg.websiteListenPort;
|
||||
QA_API_BASE_URL = "http://${apiListen}";
|
||||
NODE_ENV = "production";
|
||||
};
|
||||
serviceConfig = {
|
||||
DynamicUser = true;
|
||||
ExecStart = "${package}/bin/jet-website";
|
||||
NoNewPrivileges = true;
|
||||
PrivateTmp = true;
|
||||
ProtectHome = true;
|
||||
ProtectSystem = "strict";
|
||||
Restart = "on-failure";
|
||||
RestartSec = 5;
|
||||
};
|
||||
};
|
||||
|
||||
services.tor = lib.mkIf cfg.tor.enable {
|
||||
enable = true;
|
||||
relay.onionServices.jetpham-website = {
|
||||
|
|
|
|||
7893
package-lock.json
generated
7893
package-lock.json
generated
File diff suppressed because it is too large
Load diff
19
package.json
19
package.json
|
|
@ -7,8 +7,8 @@
|
|||
"build": "vite build",
|
||||
"check": "npm run lint && tsc --noEmit",
|
||||
"dev": "vite",
|
||||
"format:check": "prettier --check \"**/*.{ts,js,jsx,mdx}\" --cache",
|
||||
"format:write": "prettier --write \"**/*.{ts,js,jsx,mdx}\" --cache",
|
||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"preview": "vite preview",
|
||||
|
|
@ -17,6 +17,8 @@
|
|||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@types/node": "^25.3.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"anser": "^2.3.5",
|
||||
"escape-carriage": "^1.3.1",
|
||||
"eslint": "^10",
|
||||
|
|
@ -25,8 +27,15 @@
|
|||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-singlefile": "^2.3.0"
|
||||
"vite": "^7.3.1"
|
||||
},
|
||||
"knip": {}
|
||||
"knip": {},
|
||||
"dependencies": {
|
||||
"@tanstack/react-router": "^1.168.24",
|
||||
"@tanstack/react-start": "^1.167.49",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"vinxi": "^0.5.11"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
2
public/.well-known/security.txt
Normal file
2
public/.well-known/security.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
Contact: jet@extremist.software
|
||||
If you really did find something insecure about this website, I'll take you out to dinner if you're in SF. Obviously besides the fact that the qa page literally sends an email directly to me
|
||||
1
public/humans.txt
Normal file
1
public/humans.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
Hi! I hope that you have a great day. Thank you for visiting my site!
|
||||
1
public/llms.txt
Normal file
1
public/llms.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
go fuck yourself
|
||||
|
|
@ -6,9 +6,9 @@
|
|||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://jetpham.com/projects</loc>
|
||||
<loc>https://jetpham.com/colophon</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://jetpham.com/qa</loc>
|
||||
|
|
|
|||
97
server.mjs
Normal file
97
server.mjs
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { createReadStream } from "node:fs";
|
||||
import { stat } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { extname, join, normalize } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import startServer from "./dist/server/server.js";
|
||||
|
||||
const root = fileURLToPath(new URL(".", import.meta.url));
|
||||
const clientDir = join(root, "dist", "client");
|
||||
const host = process.env.HOST ?? process.env.WEBSITE_LISTEN_ADDRESS ?? "127.0.0.1";
|
||||
const port = Number(process.env.PORT ?? process.env.WEBSITE_LISTEN_PORT ?? "3002");
|
||||
|
||||
const contentTypes = new Map([
|
||||
[".css", "text/css; charset=utf-8"],
|
||||
[".html", "text/html; charset=utf-8"],
|
||||
[".ico", "image/x-icon"],
|
||||
[".js", "text/javascript; charset=utf-8"],
|
||||
[".json", "application/json; charset=utf-8"],
|
||||
[".png", "image/png"],
|
||||
[".svg", "image/svg+xml"],
|
||||
[".txt", "text/plain; charset=utf-8"],
|
||||
[".woff", "font/woff"],
|
||||
]);
|
||||
|
||||
function writeResponse(res, response) {
|
||||
res.writeHead(response.status, Object.fromEntries(response.headers));
|
||||
if (!response.body) {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
response.body.pipeTo(
|
||||
new WritableStream({
|
||||
write(chunk) {
|
||||
res.write(chunk);
|
||||
},
|
||||
close() {
|
||||
res.end();
|
||||
},
|
||||
abort(error) {
|
||||
res.destroy(error);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function tryServeStatic(req, res, pathname) {
|
||||
if (pathname === "/" || !extname(pathname)) return false;
|
||||
|
||||
const decoded = decodeURIComponent(pathname);
|
||||
const normalized = normalize(decoded).replace(/^\.\.(\/|$)/, "");
|
||||
const filePath = join(clientDir, normalized);
|
||||
|
||||
if (!filePath.startsWith(clientDir)) return false;
|
||||
|
||||
try {
|
||||
const fileStat = await stat(filePath);
|
||||
if (!fileStat.isFile()) return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
"Content-Type": contentTypes.get(extname(filePath)) ?? "application/octet-stream",
|
||||
"Cache-Control": filePath.includes("/assets/")
|
||||
? "public, max-age=31536000, immutable"
|
||||
: "no-store",
|
||||
});
|
||||
createReadStream(filePath).pipe(res);
|
||||
return true;
|
||||
}
|
||||
|
||||
createServer(async (req, res) => {
|
||||
try {
|
||||
const origin = `http://${req.headers.host ?? `${host}:${port}`}`;
|
||||
const url = new URL(req.url ?? "/", origin);
|
||||
|
||||
if (req.method === "GET" || req.method === "HEAD") {
|
||||
const served = await tryServeStatic(req, res, url.pathname);
|
||||
if (served) return;
|
||||
}
|
||||
|
||||
const request = new Request(url, {
|
||||
method: req.method,
|
||||
headers: req.headers,
|
||||
body: req.method === "GET" || req.method === "HEAD" ? undefined : req,
|
||||
duplex: "half",
|
||||
});
|
||||
const response = await startServer.fetch(request);
|
||||
writeResponse(res, response);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
res.end("Internal Server Error");
|
||||
}
|
||||
}).listen(port, host, () => {
|
||||
console.log(`Listening on http://${host}:${port}`);
|
||||
});
|
||||
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,
|
||||
}));
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
|
|
|
|||
|
|
@ -1,14 +1,11 @@
|
|||
import { defineConfig } from "vite";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { viteSingleFile } from "vite-plugin-singlefile";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
|
||||
import ansi from "./vite-plugin-ansi";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
ansi(),
|
||||
tailwindcss(),
|
||||
viteSingleFile({ useRecommendedBuildConfig: false }),
|
||||
],
|
||||
plugins: [tanstackStart(), ansi(), tailwindcss(), react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"~": "/src",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue