Compare commits

..

1 commit

Author SHA1 Message Date
Jet
a69f37886f
feat: use webgl shaders instead of wasm 2026-03-27 18:34:15 -07:00
41 changed files with 2063 additions and 9530 deletions

1
.gitignore vendored
View file

@ -10,7 +10,6 @@
# misc # misc
.DS_Store .DS_Store
*.pem *.pem
*.har
# debug # debug
npm-debug.log* npm-debug.log*

View file

@ -4,8 +4,6 @@ Personal site for Jet Pham.
The site is a small Vite app with a terminal-style UI, ANSI-rendered text, and a WebGL2 Conway's Game of Life background. The site is a small Vite app with a terminal-style UI, ANSI-rendered text, and a WebGL2 Conway's Game of Life background.
It also ships as a Nix flake with a reusable NixOS module for serving the static frontend and the Q+A API on a host.
## Features ## Features
- ASCII/ANSI-inspired visual style with the IBM VGA font - ASCII/ANSI-inspired visual style with the IBM VGA font
@ -55,39 +53,9 @@ npm run build
## Structure ## Structure
```text ```text
api/ Q+A backend
module.nix NixOS module
src/ frontend app src/ frontend app
``` ```
## NixOS module
Import the module from the flake and point it at the host-managed secret files you want to use.
```nix
{
inputs.website.url = "github:jetpham/website";
outputs = { self, nixpkgs, website, ... }: {
nixosConfigurations.my-host = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
website.nixosModules.default
({ config, ... }: {
services.jetpham-website = {
enable = true;
domain = "jetpham.com";
webhookSecretFile = config.age.secrets.webhook-secret.path;
};
})
];
};
};
}
```
Optional Tor support is configured by setting `services.jetpham-website.tor.enable = true;` and providing the three onion key file paths.
## Notes ## Notes
- The homepage and Q+A page are the intended public pages. - The homepage and Q+A page are the intended public pages.

View file

@ -25,10 +25,6 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
let qa_reply_domain = let qa_reply_domain =
std::env::var("QA_REPLY_DOMAIN").unwrap_or_else(|_| mail_domain.clone()); std::env::var("QA_REPLY_DOMAIN").unwrap_or_else(|_| mail_domain.clone());
let webhook_secret = std::env::var("WEBHOOK_SECRET").expect("WEBHOOK_SECRET must be set"); let webhook_secret = std::env::var("WEBHOOK_SECRET").expect("WEBHOOK_SECRET must be set");
let listen_address =
std::env::var("QA_LISTEN_ADDRESS").unwrap_or_else(|_| "127.0.0.1".to_string());
let listen_port = std::env::var("QA_LISTEN_PORT").unwrap_or_else(|_| "3003".to_string());
let listen_target = format!("{listen_address}:{listen_port}");
let conn = Connection::open(&db_path)?; let conn = Connection::open(&db_path)?;
conn.execute_batch("PRAGMA journal_mode=WAL;")?; conn.execute_batch("PRAGMA journal_mode=WAL;")?;
@ -62,8 +58,8 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
.layer(CorsLayer::permissive()) .layer(CorsLayer::permissive())
.with_state(state); .with_state(state);
let listener = tokio::net::TcpListener::bind(&listen_target).await?; let listener = tokio::net::TcpListener::bind("127.0.0.1:3003").await?;
println!("Listening on {listen_target}"); println!("Listening on 127.0.0.1:3003");
axum::serve(listener, app).await?; axum::serve(listener, app).await?;
Ok(()) Ok(())
} }

2
check_cleanup.txt Normal file
View file

@ -0,0 +1,2 @@
$ bun run lint && tsc --noEmit
$ eslint .

View file

@ -5,7 +5,7 @@ export default tseslint.config(
ignores: ["dist"], ignores: ["dist"],
}, },
{ {
files: ["**/*.{ts,tsx}"], files: ["**/*.ts"],
extends: [ extends: [
...tseslint.configs.recommended, ...tseslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked, ...tseslint.configs.recommendedTypeChecked,

121
flake.lock generated
View file

@ -1,8 +1,53 @@
{ {
"nodes": { "nodes": {
"agenix": {
"inputs": {
"darwin": "darwin",
"home-manager": "home-manager",
"nixpkgs": [
"nixpkgs"
],
"systems": "systems"
},
"locked": {
"lastModified": 1770165109,
"narHash": "sha256-9VnK6Oqai65puVJ4WYtCTvlJeXxMzAp/69HhQuTdl/I=",
"owner": "ryantm",
"repo": "agenix",
"rev": "b027ee29d959fda4b60b57566d64c98a202e0feb",
"type": "github"
},
"original": {
"owner": "ryantm",
"repo": "agenix",
"type": "github"
}
},
"darwin": {
"inputs": {
"nixpkgs": [
"agenix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1744478979,
"narHash": "sha256-dyN+teG9G82G+m+PX/aSAagkC+vUv0SgUw3XkPhQodQ=",
"owner": "lnl7",
"repo": "nix-darwin",
"rev": "43975d782b418ebf4969e9ccba82466728c2851b",
"type": "github"
},
"original": {
"owner": "lnl7",
"ref": "master",
"repo": "nix-darwin",
"type": "github"
}
},
"flake-utils": { "flake-utils": {
"inputs": { "inputs": {
"systems": "systems" "systems": "systems_2"
}, },
"locked": { "locked": {
"lastModified": 1731533236, "lastModified": 1731533236,
@ -18,6 +63,27 @@
"type": "github" "type": "github"
} }
}, },
"home-manager": {
"inputs": {
"nixpkgs": [
"agenix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1745494811,
"narHash": "sha256-YZCh2o9Ua1n9uCvrvi5pRxtuVNml8X2a03qIFfRKpFs=",
"owner": "nix-community",
"repo": "home-manager",
"rev": "abfad3d2958c9e6300a883bd443512c55dfeb1be",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "home-manager",
"type": "github"
}
},
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1774386573, "lastModified": 1774386573,
@ -34,10 +100,46 @@
"type": "github" "type": "github"
} }
}, },
"nixpkgs_2": {
"locked": {
"lastModified": 1744536153,
"narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": { "root": {
"inputs": { "inputs": {
"agenix": "agenix",
"flake-utils": "flake-utils", "flake-utils": "flake-utils",
"nixpkgs": "nixpkgs" "nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1774581174,
"narHash": "sha256-258qgkMkYPkJ9qpIg63Wk8GoIbVjszkGGPU1wbVHYTk=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "a313afc75b85fc77ac154bf0e62c36f68361fd0b",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
} }
}, },
"systems": { "systems": {
@ -54,6 +156,21 @@
"repo": "default", "repo": "default",
"type": "github" "type": "github"
} }
},
"systems_2": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
} }
}, },
"root": "root", "root": "root",

106
flake.nix
View file

@ -2,50 +2,100 @@
description = "Jet Pham's personal website"; description = "Jet Pham's personal website";
inputs = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
rust-overlay.url = "github:oxalica/rust-overlay";
flake-utils.url = "github:numtide/flake-utils"; flake-utils.url = "github:numtide/flake-utils";
agenix.url = "github:ryantm/agenix";
agenix.inputs.nixpkgs.follows = "nixpkgs";
}; };
outputs = outputs =
{ {
self, self,
nixpkgs, nixpkgs,
rust-overlay,
flake-utils, flake-utils,
agenix,
}: }:
(flake-utils.lib.eachDefaultSystem ( (flake-utils.lib.eachDefaultSystem (
system: system:
let let
pkgs = import nixpkgs { inherit system; }; overlays = [ (import rust-overlay) ];
lib = pkgs.lib; pkgs = import nixpkgs { inherit system overlays; };
websiteSrc = lib.fileset.toSource { agenixPkg = agenix.packages.${system}.default;
root = ./.; rustToolchain = pkgs.rust-bin.selectLatestNightlyWith (
fileset = lib.fileset.unions [ toolchain:
./index.html toolchain.default.override {
./package-lock.json extensions = [ "rust-src" ];
./package.json targets = [ "wasm32-unknown-unknown" ];
./public }
./server.mjs );
./src rustPlatform = pkgs.makeRustPlatform {
./tsconfig.json cargo = rustToolchain;
./vite-plugin-ansi.ts rustc = rustToolchain;
./vite.config.ts
];
}; };
website = pkgs.buildNpmPackage { cgol-wasm = rustPlatform.buildRustPackage {
pname = "jet-website"; pname = "cgol-wasm";
version = "0.1.0"; version = "0.1.0";
src = websiteSrc; src = ./cgol;
npmDepsHash = "sha256-tcWPiPTOfCEKBBt/ZilAnFcfWKD3FkWUM49vLqw41f0="; cargoLock.lockFile = ./cgol/Cargo.lock;
doCheck = false;
nativeBuildInputs = [
pkgs.wasm-bindgen-cli
pkgs.binaryen
];
buildPhase = ''
runHook preBuild
cargo build --release --frozen --target wasm32-unknown-unknown
wasm-bindgen --target web --out-dir pkg target/wasm32-unknown-unknown/release/cgol.wasm
wasm-opt pkg/cgol_bg.wasm -o pkg/cgol_bg.wasm -O4 \
--enable-bulk-memory --enable-nontrapping-float-to-int \
--enable-sign-ext --low-memory-unused --converge
runHook postBuild
'';
installPhase = '' installPhase = ''
runHook preInstall runHook preInstall
mkdir -p $out/share/jet-website $out/bin mkdir -p $out
cp -r dist node_modules package.json server.mjs $out/share/jet-website/ cp pkg/cgol_bg.wasm $out/
makeWrapper ${pkgs.nodejs}/bin/node $out/bin/jet-website \ cp pkg/cgol.js $out/
--add-flags $out/share/jet-website/server.mjs cp pkg/cgol.d.ts $out/
cp pkg/cgol_bg.wasm.d.ts $out/ 2>/dev/null || true
cat > $out/package.json <<'EOF'
{
"name": "cgol",
"type": "module",
"version": "0.1.0",
"files": ["cgol_bg.wasm", "cgol.js", "cgol.d.ts"],
"main": "cgol.js",
"types": "cgol.d.ts",
"sideEffects": ["./snippets/*"]
}
EOF
runHook postInstall runHook postInstall
''; '';
};
nativeBuildInputs = [ pkgs.makeWrapper ]; # Stage 2: Build the website with npm
website = pkgs.buildNpmPackage {
pname = "jet-website";
version = "0.1.0";
src = pkgs.lib.cleanSource ./.;
npmDepsHash = "sha256-O4ZUSYyVWOxP15saIadsaZuRO47Y0AvsL4pwvo5b76U=";
# Inject the Nix-built WASM before npm install resolves file: dep
postPatch = ''
mkdir -p cgol/pkg
cp -r ${cgol-wasm}/* cgol/pkg/
'';
installPhase = ''
runHook preInstall
mkdir -p $out
cp -r dist/* $out/
runHook postInstall
'';
}; };
qa-api = pkgs.rustPlatform.buildRustPackage { qa-api = pkgs.rustPlatform.buildRustPackage {
pname = "jetpham-qa-api"; pname = "jetpham-qa-api";
@ -60,6 +110,7 @@
{ {
packages = { packages = {
default = website; default = website;
cgol-wasm = cgol-wasm;
inherit qa-api; inherit qa-api;
}; };
devShells.default = pkgs.mkShell { devShells.default = pkgs.mkShell {
@ -68,11 +119,12 @@
git git
curl curl
openssl openssl
agenixPkg
typescript-language-server typescript-language-server
rust-analyzer
rustc
cargo
pkg-config pkg-config
wasm-pack
binaryen
rustToolchain
]; ];
}; };
} }

View file

@ -46,9 +46,219 @@
name="twitter:image" name="twitter:image"
content="https://jetpham.com/web-app-manifest-512x512.png" 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> </head>
<body style="background: #000"> <body style="background: #000">
<div id="root"></div> <a class="skip-link" href="#outlet">Skip to content</a>
<script type="module" src="/src/client.tsx"></script> <canvas
id="canvas"
class="pointer-events-none fixed inset-0 z-0 h-screen w-screen"
aria-hidden="true"
></canvas>
<aside id="effect-tuner" class="effect-tuner" aria-label="Effect tuning">
<h2>[EFFECT TUNER]</h2>
<label>
<span>Blur</span>
<input
id="effect-blur"
type="range"
min="0"
max="16"
step="1"
value="1"
/>
<output for="effect-blur" id="effect-blur-value">1</output>
</label>
<label>
<span>Radius</span>
<input
id="effect-radius"
type="range"
min="1"
max="60"
step="1"
value="20"
/>
<output for="effect-radius" id="effect-radius-value">20.0</output>
</label>
<label>
<span>Darkness</span>
<input
id="effect-darkness"
type="range"
min="0"
max="1"
step="0.005"
value="0.20"
/>
<output for="effect-darkness" id="effect-darkness-value">0.20</output>
</label>
<label>
<span>Smallest</span>
<input
id="effect-smallest"
type="range"
min="0.25"
max="32"
step="0.25"
value="1"
/>
<output for="effect-smallest" id="effect-smallest-value">1.00</output>
</label>
<label>
<span>Largest</span>
<input
id="effect-largest"
type="range"
min="1"
max="256"
step="1"
value="20"
/>
<output for="effect-largest" id="effect-largest-value">20</output>
</label>
<label>
<span>Levels</span>
<input
id="effect-levels"
type="range"
min="1"
max="10"
step="1"
value="3"
/>
<output for="effect-levels" id="effect-levels-value">3</output>
</label>
<label>
<span>Detail</span>
<input
id="effect-detail"
type="range"
min="0.001"
max="0.5"
step="0.001"
value="0.04"
/>
<output for="effect-detail" id="effect-detail-value">0.040</output>
</label>
<label>
<span>Dither</span>
<input
id="effect-dither"
type="range"
min="0"
max="2"
step="0.01"
value="0.75"
/>
<output for="effect-dither" id="effect-dither-value">0.75</output>
</label>
<label>
<span>Edge Bias</span>
<input
id="effect-edge"
type="range"
min="0"
max="3"
step="0.05"
value="1.35"
/>
<output for="effect-edge" id="effect-edge-value">1.35</output>
</label>
<label>
<span>Hue Drift</span>
<input
id="effect-hue-drift"
type="range"
min="0"
max="0.25"
step="0.005"
value="0.08"
/>
<output for="effect-hue-drift" id="effect-hue-drift-value"
>0.080</output
>
</label>
<label>
<span>Streak</span>
<input
id="effect-streak"
type="range"
min="0"
max="1"
step="0.01"
value="0.35"
/>
<output for="effect-streak" id="effect-streak-value">0.35</output>
</label>
</aside>
<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 flex justify-center gap-[2ch]">
<a href="/" data-nav-link>[HOME]</a>
<a href="/qa" data-nav-link>[Q&amp;A]</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>
</body> </body>
</html> </html>

View file

@ -1,150 +1,27 @@
self: self:
{ { config, lib, ... }:
config,
lib,
pkgs,
...
}:
let let
cfg = config.services.jetpham-website; cfg = config.services.jetpham-website;
package = cfg.package; package = self.packages.x86_64-linux.default;
qaApi = cfg.apiPackage; qaApi = self.packages.x86_64-linux.qa-api;
websiteListen = "${cfg.websiteListenAddress}:${toString cfg.websiteListenPort}";
apiListen = "${cfg.apiListenAddress}:${toString cfg.apiListenPort}";
usingDefaultWebhookSecret = cfg.webhookSecretFile == null;
webhookSecretPath = webhookSecretPath =
if usingDefaultWebhookSecret then config.age.secrets.webhook-secret.path else cfg.webhookSecretFile; if cfg.webhookSecretFile != null then
usingDefaultTorSecretKey = cfg.tor.onionSecretKeyFile == null; cfg.webhookSecretFile
usingDefaultTorPublicKey = cfg.tor.onionPublicKeyFile == null;
usingDefaultTorHostname = cfg.tor.onionHostnameFile == null;
torOnionSecretKeyPath =
if usingDefaultTorSecretKey then
config.age.secrets.tor-onion-secret-key.path
else else
cfg.tor.onionSecretKeyFile; config.age.secrets.webhook-secret.path;
torOnionPublicKeyPath =
if usingDefaultTorPublicKey then
config.age.secrets.tor-onion-public-key.path
else
cfg.tor.onionPublicKeyFile;
torOnionHostnamePath =
if usingDefaultTorHostname then
config.age.secrets.tor-onion-hostname.path
else
cfg.tor.onionHostnameFile;
caddyCommonConfig = ''
header Cross-Origin-Opener-Policy "same-origin"
header Cross-Origin-Embedder-Policy "require-corp"
handle /api/* {
reverse_proxy ${apiListen}
}
handle /qa/rss.xml {
reverse_proxy ${apiListen}
}
handle {
reverse_proxy ${websiteListen}
}
${cfg.caddy.extraConfig}
'';
in in
{ {
options.services.jetpham-website = { options.services.jetpham-website = {
enable = lib.mkEnableOption "Jet Pham's personal website"; enable = lib.mkEnableOption "Jet Pham's personal website";
package = lib.mkOption {
type = lib.types.package;
default = self.packages.${pkgs.system}.default;
defaultText = lib.literalExpression "self.packages.${pkgs.system}.default";
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;
defaultText = lib.literalExpression "self.packages.${pkgs.system}.qa-api";
description = "Q&A API package run by systemd.";
};
domain = lib.mkOption { domain = lib.mkOption {
type = lib.types.str; type = lib.types.str;
default = "jetpham.com"; default = "jetpham.com";
description = "Domain to serve the website on."; description = "Domain to serve the website on.";
}; };
openFirewall = lib.mkOption { tor.enable = lib.mkEnableOption "Tor hidden service for the website";
type = lib.types.bool;
default = true;
description = "Open HTTP and HTTPS ports when Caddy is enabled.";
};
apiListenAddress = lib.mkOption {
type = lib.types.str;
default = "127.0.0.1";
description = "Address for the local Q&A API listener.";
};
apiListenPort = lib.mkOption {
type = lib.types.port;
default = 3003;
description = "Port for the local Q&A API listener.";
};
caddy.enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Serve the static site and reverse proxy the API through Caddy.";
};
caddy.extraConfig = lib.mkOption {
type = lib.types.lines;
default = "";
description = "Extra Caddy directives appended inside the virtual host block.";
};
tor = {
enable = lib.mkEnableOption "Tor hidden service for the website";
port = lib.mkOption {
type = lib.types.port;
default = 8888;
description = "Local Caddy port exposed through the onion service.";
};
onionSecretKeyFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = "Path to the Tor hidden service secret key file.";
};
onionPublicKeyFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = "Path to the Tor hidden service public key file.";
};
onionHostnameFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = "Path to the Tor hidden service hostname file.";
};
};
qaNotifyEmail = lib.mkOption { qaNotifyEmail = lib.mkOption {
type = lib.types.str; type = lib.types.str;
@ -167,76 +44,35 @@ in
webhookSecretFile = lib.mkOption { webhookSecretFile = lib.mkOption {
type = lib.types.nullOr lib.types.path; type = lib.types.nullOr lib.types.path;
default = null; default = null;
description = "File containing the WEBHOOK_SECRET for MTA Hook authentication. Defaults to the module-managed agenix secret when left unset."; description = "File containing the WEBHOOK_SECRET for MTA Hook authentication.";
}; };
}; };
config = lib.mkIf cfg.enable { config = lib.mkIf cfg.enable {
age.secrets.webhook-secret = lib.mkIf usingDefaultWebhookSecret { age.secrets.webhook-secret = lib.mkIf (cfg.webhookSecretFile == null) {
file = "${self}/secrets/webhook-secret.age"; file = "${self}/secrets/webhook-secret.age";
mode = "0400"; mode = "0400";
}; };
age.secrets.tor-onion-secret-key = lib.mkIf (cfg.tor.enable && usingDefaultTorSecretKey) { age.secrets.tor-onion-secret-key = lib.mkIf cfg.tor.enable {
file = "${self}/secrets/tor-onion-secret-key.age"; file = "${self}/secrets/tor-onion-secret-key.age";
owner = "tor"; owner = "tor";
group = "tor"; group = "tor";
mode = "0400"; mode = "0400";
}; };
age.secrets.tor-onion-public-key = lib.mkIf cfg.tor.enable {
age.secrets.tor-onion-public-key = lib.mkIf (cfg.tor.enable && usingDefaultTorPublicKey) {
file = "${self}/secrets/tor-onion-public-key.age"; file = "${self}/secrets/tor-onion-public-key.age";
owner = "tor"; owner = "tor";
group = "tor"; group = "tor";
mode = "0444"; mode = "0444";
}; };
age.secrets.tor-onion-hostname = lib.mkIf cfg.tor.enable {
age.secrets.tor-onion-hostname = lib.mkIf (cfg.tor.enable && usingDefaultTorHostname) {
file = "${self}/secrets/tor-onion-hostname.age"; file = "${self}/secrets/tor-onion-hostname.age";
owner = "tor"; owner = "tor";
group = "tor"; group = "tor";
mode = "0444"; mode = "0444";
}; };
assertions = [
{
assertion =
!cfg.tor.enable
|| (torOnionSecretKeyPath != null && torOnionPublicKeyPath != null && torOnionHostnamePath != null);
message = "services.jetpham-website.tor requires onionSecretKeyFile, onionPublicKeyFile, and onionHostnameFile.";
}
];
networking.firewall.allowedTCPPorts = lib.mkIf (cfg.caddy.enable && cfg.openFirewall) [
80
443
];
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 { services.tor = lib.mkIf cfg.tor.enable {
enable = true; enable = true;
relay.onionServices.jetpham-website = { relay.onionServices.jetpham-website = {
@ -245,7 +81,7 @@ in
port = 80; port = 80;
target = { target = {
addr = "127.0.0.1"; addr = "127.0.0.1";
port = cfg.tor.port; port = 8888;
}; };
} }
]; ];
@ -254,47 +90,36 @@ in
systemd.services.tor-onion-keys = lib.mkIf cfg.tor.enable { systemd.services.tor-onion-keys = lib.mkIf cfg.tor.enable {
description = "Copy Tor onion keys into place"; description = "Copy Tor onion keys into place";
after = lib.optional ( after = [ "agenix.service" ];
usingDefaultTorSecretKey || usingDefaultTorPublicKey || usingDefaultTorHostname
) "agenix.service";
before = [ "tor.service" ]; before = [ "tor.service" ];
wantedBy = [ "tor.service" ]; wantedBy = [ "tor.service" ];
serviceConfig.Type = "oneshot"; serviceConfig.Type = "oneshot";
script = '' script = ''
dir="/var/lib/tor/onion/jetpham-website" dir="/var/lib/tor/onion/jetpham-website"
mkdir -p "$dir" mkdir -p "$dir"
cp ${torOnionSecretKeyPath} "$dir/hs_ed25519_secret_key" cp ${config.age.secrets.tor-onion-secret-key.path} "$dir/hs_ed25519_secret_key"
cp ${torOnionPublicKeyPath} "$dir/hs_ed25519_public_key" cp ${config.age.secrets.tor-onion-public-key.path} "$dir/hs_ed25519_public_key"
cp ${torOnionHostnamePath} "$dir/hostname" cp ${config.age.secrets.tor-onion-hostname.path} "$dir/hostname"
chown -R tor:tor "$dir" chown -R tor:tor "$dir"
chmod 700 "$dir" chmod 700 "$dir"
chmod 400 "$dir/hs_ed25519_secret_key" chmod 400 "$dir/hs_ed25519_secret_key"
chmod 444 "$dir/hs_ed25519_public_key" "$dir/hostname" chmod 444 "$dir/hs_ed25519_public_key" "$dir/hostname"
''; '';
}; };
# Q&A API systemd service
systemd.services.jetpham-qa-api = { systemd.services.jetpham-qa-api = {
description = "Jet Pham Q&A API"; description = "Jet Pham Q&A API";
after = [ "network-online.target" ] ++ lib.optional usingDefaultWebhookSecret "agenix.service"; after = [ "network.target" ];
wants = [ "network-online.target" ] ++ lib.optional usingDefaultWebhookSecret "agenix.service";
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
serviceConfig = { serviceConfig = {
DynamicUser = true; DynamicUser = true;
StateDirectory = "jetpham-qa"; StateDirectory = "jetpham-qa";
WorkingDirectory = "/var/lib/jetpham-qa";
Environment = [ Environment = [
"QA_DB_PATH=/var/lib/jetpham-qa/qa.db" "QA_DB_PATH=/var/lib/jetpham-qa/qa.db"
"QA_NOTIFY_EMAIL=${cfg.qaNotifyEmail}" "QA_NOTIFY_EMAIL=${cfg.qaNotifyEmail}"
"QA_MAIL_DOMAIN=${cfg.qaMailDomain}" "QA_MAIL_DOMAIN=${cfg.qaMailDomain}"
"QA_REPLY_DOMAIN=${cfg.qaReplyDomain}" "QA_REPLY_DOMAIN=${cfg.qaReplyDomain}"
"QA_LISTEN_ADDRESS=${cfg.apiListenAddress}"
"QA_LISTEN_PORT=${toString cfg.apiListenPort}"
]; ];
NoNewPrivileges = true;
PrivateTmp = true;
ProtectHome = true;
ProtectSystem = "strict";
ReadWritePaths = [ "/var/lib/jetpham-qa" ];
Restart = "on-failure"; Restart = "on-failure";
RestartSec = 5; RestartSec = 5;
LoadCredential = "webhook-secret:${webhookSecretPath}"; LoadCredential = "webhook-secret:${webhookSecretPath}";
@ -310,17 +135,47 @@ in
''; '';
}; };
services.caddy.virtualHosts.${cfg.domain} = lib.mkIf cfg.caddy.enable { services.caddy.virtualHosts.${cfg.domain} = {
extraConfig = caddyCommonConfig; extraConfig = ''
header Cross-Origin-Opener-Policy "same-origin"
header Cross-Origin-Embedder-Policy "require-corp"
handle /api/* {
reverse_proxy 127.0.0.1:3003
}
handle /qa/rss.xml {
reverse_proxy 127.0.0.1:3003
}
handle {
root * ${package}
try_files {path} /index.html
file_server
}
'';
}; };
services.caddy.virtualHosts."http://:${toString cfg.tor.port}" = services.caddy.virtualHosts."http://:8888" = lib.mkIf cfg.tor.enable {
lib.mkIf (cfg.caddy.enable && cfg.tor.enable) extraConfig = ''
{ bind 127.0.0.1
extraConfig = '' header Cross-Origin-Opener-Policy "same-origin"
bind 127.0.0.1 header Cross-Origin-Embedder-Policy "require-corp"
${caddyCommonConfig}
''; handle /api/* {
}; reverse_proxy 127.0.0.1:3003
}
handle /qa/rss.xml {
reverse_proxy 127.0.0.1:3003
}
handle {
root * ${package}
try_files {path} /index.html
file_server
}
'';
};
}; };
} }

7895
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -7,8 +7,8 @@
"build": "vite build", "build": "vite build",
"check": "npm run lint && tsc --noEmit", "check": "npm run lint && tsc --noEmit",
"dev": "vite", "dev": "vite",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache", "format:check": "prettier --check \"**/*.{ts,js,jsx,mdx}\" --cache",
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache", "format:write": "prettier --write \"**/*.{ts,js,jsx,mdx}\" --cache",
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"preview": "vite preview", "preview": "vite preview",
@ -17,8 +17,6 @@
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.1",
"@types/node": "^25.3.3", "@types/node": "^25.3.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"anser": "^2.3.5", "anser": "^2.3.5",
"escape-carriage": "^1.3.1", "escape-carriage": "^1.3.1",
"eslint": "^10", "eslint": "^10",
@ -27,15 +25,8 @@
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.56.1", "typescript-eslint": "^8.56.1",
"vite": "^7.3.1" "vite": "^7.3.1",
"vite-plugin-singlefile": "^2.3.0"
}, },
"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"
}
} }

View file

@ -1,2 +0,0 @@
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

View file

@ -1 +0,0 @@
Hi! I hope that you have a great day. Thank you for visiting my site!

View file

@ -1 +0,0 @@
go fuck yourself

View file

@ -6,9 +6,9 @@
<priority>1.0</priority> <priority>1.0</priority>
</url> </url>
<url> <url>
<loc>https://jetpham.com/colophon</loc> <loc>https://jetpham.com/projects</loc>
<changefreq>monthly</changefreq> <changefreq>monthly</changefreq>
<priority>0.6</priority> <priority>0.8</priority>
</url> </url>
<url> <url>
<loc>https://jetpham.com/qa</loc> <loc>https://jetpham.com/qa</loc>

View file

@ -1,97 +0,0 @@
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}`);
});

View file

@ -1,4 +0,0 @@
import { StartClient } from "@tanstack/react-start/client";
import { hydrateRoot } from "react-dom/client";
hydrateRoot(document, <StartClient />);

View file

@ -0,0 +1,10 @@
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>`;
}

View file

@ -1,21 +0,0 @@
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>
);
}

View file

@ -1,108 +0,0 @@
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">
&gt;
</span>
<span>{children}</span>
<span className="site-nav-marker" aria-hidden="true">
&lt;
</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&amp;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 Normal file
View file

@ -0,0 +1,132 @@
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");
}
}

View file

@ -1,101 +0,0 @@
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");
}
});

46
src/lib/site.ts Normal file
View file

@ -0,0 +1,46 @@
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>`;
}

File diff suppressed because it is too large Load diff

15
src/main.ts Normal file
View file

@ -0,0 +1,15 @@
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();

67
src/pages/home.ts Normal file
View file

@ -0,0 +1,67 @@
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-[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">${Jet}</div>
<p class="mt-[2ch]">Software Extremist</p>
</div>
<div class="order-2 shrink-0 md:order-1">
<img
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-[250px] 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);
})();
});
}

13
src/pages/not-found.ts Normal file
View file

@ -0,0 +1,13 @@
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 Normal file
View file

@ -0,0 +1,283 @@
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();
}

View file

@ -1,123 +0,0 @@
/* 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>>
}
}

95
src/router.ts Normal file
View file

@ -0,0 +1,95 @@
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();
}

View file

@ -1,15 +0,0 @@
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>>;
}
}

View file

@ -1,23 +0,0 @@
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>
);
}

View file

@ -1,114 +0,0 @@
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,
});

View file

@ -1,99 +0,0 @@
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&amp;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&apos;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&amp;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&amp;A API, Caddy, and onion
service.
</p>
</fieldset>
</FrostedBox>
</div>
);
}

View file

@ -1,100 +0,0 @@
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>
);
}

View file

@ -1,248 +0,0 @@
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>
);
}

View file

@ -1,5 +0,0 @@
import { createStart } from "@tanstack/react-start";
export const startInstance = createStart(() => ({
defaultSsr: true,
}));

View file

@ -140,18 +140,12 @@ body[data-background-mode="failed"]::before {
} }
.site-shell { .site-shell {
width: 100%; width: min(100%, 60%);
box-sizing: border-box; box-sizing: border-box;
margin: 0 auto; margin: 0 auto;
user-select: text; user-select: text;
} }
@media (min-width: 768px) {
.site-shell {
width: min(100%, 60%);
}
}
.site-panel-frame { .site-panel-frame {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
@ -159,7 +153,7 @@ body[data-background-mode="failed"]::before {
.site-panel-frost { .site-panel-frost {
position: absolute; position: absolute;
inset: var(--panel-border-inset); inset: 0;
pointer-events: none; pointer-events: none;
overflow: hidden; overflow: hidden;
background: background:
@ -171,6 +165,40 @@ body[data-background-mode="failed"]::before {
var(--panel-bg); var(--panel-bg);
} }
.effect-tuner {
position: fixed;
right: 1rem;
bottom: 1rem;
z-index: 40;
width: min(26rem, calc(100vw - 2rem));
padding: 1ch 1.5ch;
border: 2px solid var(--white);
background: rgba(0, 0, 0, 0.88);
color: var(--white);
}
.effect-tuner h2 {
margin: 0 0 1ch;
font-size: 1em;
font-weight: normal;
}
.effect-tuner label {
display: grid;
grid-template-columns: 11ch 1fr 5ch;
align-items: center;
gap: 1ch;
margin-top: 0.75ch;
}
.effect-tuner input[type="range"] {
width: 100%;
}
.effect-tuner output {
text-align: right;
}
.site-panel-border { .site-panel-border {
position: absolute; position: absolute;
inset: var(--panel-border-inset); inset: var(--panel-border-inset);
@ -221,36 +249,6 @@ a[aria-current="page"] {
text-decoration: underline; text-decoration: underline;
} }
.site-nav-link {
display: inline-flex;
align-items: center;
color: var(--light-blue);
text-decoration: none;
}
.site-nav-link:hover,
.site-nav-link:focus-visible {
background-color: transparent;
color: var(--light-blue);
text-decoration: none;
}
.site-nav-link[aria-current="page"] {
color: var(--yellow);
text-decoration: none;
}
.site-nav-marker {
display: inline-block;
width: 1ch;
color: currentColor;
visibility: hidden;
}
.site-nav-link[aria-current="page"] .site-nav-marker {
visibility: visible;
}
.skip-link { .skip-link {
position: absolute; position: absolute;
left: 1rem; left: 1rem;

252
tor.har Normal file
View file

@ -0,0 +1,252 @@
{
"log": {
"version": "1.2",
"creator": {
"name": "Firefox",
"version": "140.8.0"
},
"browser": {
"name": "Firefox",
"version": "140.8.0"
},
"pages": [
{
"id": "page_1",
"pageTimings": {
"onContentLoad": 1578,
"onLoad": 1578
},
"startedDateTime": "2026-03-26T20:03:41.824-07:00",
"title": "http://jet7tetd43snvjx3ng5jrhuwm2yhyp76tjtct5mtofg64apokcgq7fqd.onion/"
}
],
"entries": [
{
"startedDateTime": "2026-03-26T20:03:41.824-07:00",
"request": {
"bodySize": 0,
"method": "GET",
"url": "http://jet7tetd43snvjx3ng5jrhuwm2yhyp76tjtct5mtofg64apokcgq7fqd.onion/",
"httpVersion": "HTTP/1.1",
"headers": [
{
"name": "Host",
"value": "jet7tetd43snvjx3ng5jrhuwm2yhyp76tjtct5mtofg64apokcgq7fqd.onion"
},
{
"name": "User-Agent",
"value": "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0"
},
{
"name": "Accept",
"value": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
},
{
"name": "Accept-Language",
"value": "en-US,en;q=0.5"
},
{
"name": "Accept-Encoding",
"value": "gzip, deflate, br, zstd"
},
{
"name": "Sec-GPC",
"value": "1"
},
{
"name": "Connection",
"value": "keep-alive"
},
{
"name": "Upgrade-Insecure-Requests",
"value": "1"
},
{
"name": "Sec-Fetch-Dest",
"value": "document"
},
{
"name": "Sec-Fetch-Mode",
"value": "navigate"
},
{
"name": "Sec-Fetch-Site",
"value": "none"
},
{
"name": "Priority",
"value": "u=0, i"
},
{
"name": "Pragma",
"value": "no-cache"
},
{
"name": "Cache-Control",
"value": "no-cache"
}
],
"cookies": [],
"queryString": [],
"headersSize": 521
},
"response": {
"status": 200,
"statusText": "OK",
"httpVersion": "HTTP/1.1",
"headers": [
{
"name": "Server",
"value": "Caddy"
},
{
"name": "Date",
"value": "Fri, 27 Mar 2026 03:03:43 GMT"
},
{
"name": "Content-Length",
"value": "0"
}
],
"cookies": [],
"content": {
"mimeType": "text/plain",
"size": 0,
"text": ""
},
"redirectURL": "",
"headersSize": 90,
"bodySize": 90
},
"cache": {},
"timings": {
"blocked": 894,
"dns": 0,
"connect": 894,
"ssl": 0,
"send": 0,
"wait": 617,
"receive": 0
},
"time": 2405,
"_securityState": "insecure",
"serverIPAddress": "0.0.0.0",
"connection": "80",
"pageref": "page_1"
},
{
"startedDateTime": "2026-03-26T20:03:43.440-07:00",
"request": {
"bodySize": 0,
"method": "GET",
"url": "http://jet7tetd43snvjx3ng5jrhuwm2yhyp76tjtct5mtofg64apokcgq7fqd.onion/favicon.ico",
"httpVersion": "HTTP/1.1",
"headers": [
{
"name": "Host",
"value": "jet7tetd43snvjx3ng5jrhuwm2yhyp76tjtct5mtofg64apokcgq7fqd.onion"
},
{
"name": "User-Agent",
"value": "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0"
},
{
"name": "Accept",
"value": "image/avif,image/webp,image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5"
},
{
"name": "Accept-Language",
"value": "en-US,en;q=0.5"
},
{
"name": "Accept-Encoding",
"value": "gzip, deflate, br, zstd"
},
{
"name": "Referer",
"value": "http://jet7tetd43snvjx3ng5jrhuwm2yhyp76tjtct5mtofg64apokcgq7fqd.onion/"
},
{
"name": "Sec-GPC",
"value": "1"
},
{
"name": "Connection",
"value": "keep-alive"
},
{
"name": "Sec-Fetch-Dest",
"value": "image"
},
{
"name": "Sec-Fetch-Mode",
"value": "no-cors"
},
{
"name": "Sec-Fetch-Site",
"value": "same-origin"
},
{
"name": "Priority",
"value": "u=6"
},
{
"name": "Pragma",
"value": "no-cache"
},
{
"name": "Cache-Control",
"value": "no-cache"
}
],
"cookies": [],
"queryString": [],
"headersSize": 589
},
"response": {
"status": 200,
"statusText": "OK",
"httpVersion": "HTTP/1.1",
"headers": [
{
"name": "Server",
"value": "Caddy"
},
{
"name": "Date",
"value": "Fri, 27 Mar 2026 03:03:43 GMT"
},
{
"name": "Content-Length",
"value": "0"
}
],
"cookies": [],
"content": {
"mimeType": "text/plain",
"size": 0,
"text": ""
},
"redirectURL": "",
"headersSize": 90,
"bodySize": 90
},
"cache": {},
"timings": {
"blocked": 0,
"dns": 0,
"connect": 0,
"ssl": 0,
"send": 0,
"wait": 604,
"receive": 0
},
"time": 604,
"_securityState": "insecure",
"serverIPAddress": "0.0.0.0",
"connection": "80",
"pageref": "page_1"
}
]
}
}

View file

@ -3,7 +3,6 @@
"target": "ESNext", "target": "ESNext",
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true, "strict": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"noUncheckedIndexedAccess": true, "noUncheckedIndexedAccess": true,

View file

@ -1,11 +1,14 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react"; import { viteSingleFile } from "vite-plugin-singlefile";
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import ansi from "./vite-plugin-ansi"; import ansi from "./vite-plugin-ansi";
export default defineConfig({ export default defineConfig({
plugins: [tanstackStart(), ansi(), tailwindcss(), react()], plugins: [
ansi(),
tailwindcss(),
viteSingleFile({ useRecommendedBuildConfig: false }),
],
resolve: { resolve: {
alias: { alias: {
"~": "/src", "~": "/src",