Compare commits
1 commit
| Author | SHA1 | Date | |
|---|---|---|---|
| a69f37886f |
41 changed files with 2063 additions and 9530 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -10,7 +10,6 @@
|
|||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
*.har
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
|
|
|
|||
32
README.md
32
README.md
|
|
@ -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.
|
||||
|
||||
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
|
||||
|
||||
- ASCII/ANSI-inspired visual style with the IBM VGA font
|
||||
|
|
@ -55,39 +53,9 @@ npm run build
|
|||
## Structure
|
||||
|
||||
```text
|
||||
api/ Q+A backend
|
||||
module.nix NixOS module
|
||||
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
|
||||
|
||||
- The homepage and Q+A page are the intended public pages.
|
||||
|
|
|
|||
|
|
@ -25,10 +25,6 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||
let qa_reply_domain =
|
||||
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 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)?;
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL;")?;
|
||||
|
|
@ -62,8 +58,8 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||
.layer(CorsLayer::permissive())
|
||||
.with_state(state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&listen_target).await?;
|
||||
println!("Listening on {listen_target}");
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:3003").await?;
|
||||
println!("Listening on 127.0.0.1:3003");
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
2
check_cleanup.txt
Normal file
2
check_cleanup.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
$ bun run lint && tsc --noEmit
|
||||
$ eslint .
|
||||
|
|
@ -5,7 +5,7 @@ export default tseslint.config(
|
|||
ignores: ["dist"],
|
||||
},
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
files: ["**/*.ts"],
|
||||
extends: [
|
||||
...tseslint.configs.recommended,
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
|
|
|
|||
121
flake.lock
generated
121
flake.lock
generated
|
|
@ -1,8 +1,53 @@
|
|||
{
|
||||
"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": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
"systems": "systems_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
|
|
@ -18,6 +63,27 @@
|
|||
"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": {
|
||||
"locked": {
|
||||
"lastModified": 1774386573,
|
||||
|
|
@ -34,10 +100,46 @@
|
|||
"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": {
|
||||
"inputs": {
|
||||
"agenix": "agenix",
|
||||
"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": {
|
||||
|
|
@ -54,6 +156,21 @@
|
|||
"repo": "default",
|
||||
"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",
|
||||
|
|
|
|||
106
flake.nix
106
flake.nix
|
|
@ -2,50 +2,100 @@
|
|||
description = "Jet Pham's personal website";
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
rust-overlay.url = "github:oxalica/rust-overlay";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
agenix.url = "github:ryantm/agenix";
|
||||
agenix.inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
outputs =
|
||||
{
|
||||
self,
|
||||
nixpkgs,
|
||||
rust-overlay,
|
||||
flake-utils,
|
||||
agenix,
|
||||
}:
|
||||
(flake-utils.lib.eachDefaultSystem (
|
||||
system:
|
||||
let
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
lib = pkgs.lib;
|
||||
websiteSrc = lib.fileset.toSource {
|
||||
root = ./.;
|
||||
fileset = lib.fileset.unions [
|
||||
./index.html
|
||||
./package-lock.json
|
||||
./package.json
|
||||
./public
|
||||
./server.mjs
|
||||
./src
|
||||
./tsconfig.json
|
||||
./vite-plugin-ansi.ts
|
||||
./vite.config.ts
|
||||
];
|
||||
overlays = [ (import rust-overlay) ];
|
||||
pkgs = import nixpkgs { inherit system overlays; };
|
||||
agenixPkg = agenix.packages.${system}.default;
|
||||
rustToolchain = pkgs.rust-bin.selectLatestNightlyWith (
|
||||
toolchain:
|
||||
toolchain.default.override {
|
||||
extensions = [ "rust-src" ];
|
||||
targets = [ "wasm32-unknown-unknown" ];
|
||||
}
|
||||
);
|
||||
rustPlatform = pkgs.makeRustPlatform {
|
||||
cargo = rustToolchain;
|
||||
rustc = rustToolchain;
|
||||
};
|
||||
|
||||
website = pkgs.buildNpmPackage {
|
||||
pname = "jet-website";
|
||||
cgol-wasm = rustPlatform.buildRustPackage {
|
||||
pname = "cgol-wasm";
|
||||
version = "0.1.0";
|
||||
src = websiteSrc;
|
||||
npmDepsHash = "sha256-tcWPiPTOfCEKBBt/ZilAnFcfWKD3FkWUM49vLqw41f0=";
|
||||
src = ./cgol;
|
||||
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 = ''
|
||||
runHook preInstall
|
||||
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
|
||||
mkdir -p $out
|
||||
cp pkg/cgol_bg.wasm $out/
|
||||
cp pkg/cgol.js $out/
|
||||
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
|
||||
'';
|
||||
};
|
||||
|
||||
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 {
|
||||
pname = "jetpham-qa-api";
|
||||
|
|
@ -60,6 +110,7 @@
|
|||
{
|
||||
packages = {
|
||||
default = website;
|
||||
cgol-wasm = cgol-wasm;
|
||||
inherit qa-api;
|
||||
};
|
||||
devShells.default = pkgs.mkShell {
|
||||
|
|
@ -68,11 +119,12 @@
|
|||
git
|
||||
curl
|
||||
openssl
|
||||
agenixPkg
|
||||
typescript-language-server
|
||||
rust-analyzer
|
||||
rustc
|
||||
cargo
|
||||
pkg-config
|
||||
wasm-pack
|
||||
binaryen
|
||||
rustToolchain
|
||||
];
|
||||
};
|
||||
}
|
||||
|
|
|
|||
214
index.html
214
index.html
|
|
@ -46,9 +46,219 @@
|
|||
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">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/client.tsx"></script>
|
||||
<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>
|
||||
<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&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>
|
||||
</html>
|
||||
|
|
|
|||
255
module.nix
255
module.nix
|
|
@ -1,150 +1,27 @@
|
|||
self:
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
{ config, lib, ... }:
|
||||
|
||||
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;
|
||||
package = self.packages.x86_64-linux.default;
|
||||
qaApi = self.packages.x86_64-linux.qa-api;
|
||||
webhookSecretPath =
|
||||
if usingDefaultWebhookSecret then config.age.secrets.webhook-secret.path else cfg.webhookSecretFile;
|
||||
usingDefaultTorSecretKey = cfg.tor.onionSecretKeyFile == null;
|
||||
usingDefaultTorPublicKey = cfg.tor.onionPublicKeyFile == null;
|
||||
usingDefaultTorHostname = cfg.tor.onionHostnameFile == null;
|
||||
torOnionSecretKeyPath =
|
||||
if usingDefaultTorSecretKey then
|
||||
config.age.secrets.tor-onion-secret-key.path
|
||||
if cfg.webhookSecretFile != null then
|
||||
cfg.webhookSecretFile
|
||||
else
|
||||
cfg.tor.onionSecretKeyFile;
|
||||
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}
|
||||
'';
|
||||
config.age.secrets.webhook-secret.path;
|
||||
in
|
||||
{
|
||||
options.services.jetpham-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 {
|
||||
type = lib.types.str;
|
||||
default = "jetpham.com";
|
||||
description = "Domain to serve the website on.";
|
||||
};
|
||||
|
||||
openFirewall = lib.mkOption {
|
||||
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.";
|
||||
};
|
||||
};
|
||||
tor.enable = lib.mkEnableOption "Tor hidden service for the website";
|
||||
|
||||
qaNotifyEmail = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
|
|
@ -167,76 +44,35 @@ in
|
|||
webhookSecretFile = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.path;
|
||||
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 {
|
||||
age.secrets.webhook-secret = lib.mkIf usingDefaultWebhookSecret {
|
||||
age.secrets.webhook-secret = lib.mkIf (cfg.webhookSecretFile == null) {
|
||||
file = "${self}/secrets/webhook-secret.age";
|
||||
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";
|
||||
owner = "tor";
|
||||
group = "tor";
|
||||
mode = "0400";
|
||||
};
|
||||
|
||||
age.secrets.tor-onion-public-key = lib.mkIf (cfg.tor.enable && usingDefaultTorPublicKey) {
|
||||
age.secrets.tor-onion-public-key = lib.mkIf cfg.tor.enable {
|
||||
file = "${self}/secrets/tor-onion-public-key.age";
|
||||
owner = "tor";
|
||||
group = "tor";
|
||||
mode = "0444";
|
||||
};
|
||||
|
||||
age.secrets.tor-onion-hostname = lib.mkIf (cfg.tor.enable && usingDefaultTorHostname) {
|
||||
age.secrets.tor-onion-hostname = lib.mkIf cfg.tor.enable {
|
||||
file = "${self}/secrets/tor-onion-hostname.age";
|
||||
owner = "tor";
|
||||
group = "tor";
|
||||
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 {
|
||||
enable = true;
|
||||
relay.onionServices.jetpham-website = {
|
||||
|
|
@ -245,7 +81,7 @@ in
|
|||
port = 80;
|
||||
target = {
|
||||
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 {
|
||||
description = "Copy Tor onion keys into place";
|
||||
after = lib.optional (
|
||||
usingDefaultTorSecretKey || usingDefaultTorPublicKey || usingDefaultTorHostname
|
||||
) "agenix.service";
|
||||
after = [ "agenix.service" ];
|
||||
before = [ "tor.service" ];
|
||||
wantedBy = [ "tor.service" ];
|
||||
serviceConfig.Type = "oneshot";
|
||||
script = ''
|
||||
dir="/var/lib/tor/onion/jetpham-website"
|
||||
mkdir -p "$dir"
|
||||
cp ${torOnionSecretKeyPath} "$dir/hs_ed25519_secret_key"
|
||||
cp ${torOnionPublicKeyPath} "$dir/hs_ed25519_public_key"
|
||||
cp ${torOnionHostnamePath} "$dir/hostname"
|
||||
cp ${config.age.secrets.tor-onion-secret-key.path} "$dir/hs_ed25519_secret_key"
|
||||
cp ${config.age.secrets.tor-onion-public-key.path} "$dir/hs_ed25519_public_key"
|
||||
cp ${config.age.secrets.tor-onion-hostname.path} "$dir/hostname"
|
||||
chown -R tor:tor "$dir"
|
||||
chmod 700 "$dir"
|
||||
chmod 400 "$dir/hs_ed25519_secret_key"
|
||||
chmod 444 "$dir/hs_ed25519_public_key" "$dir/hostname"
|
||||
'';
|
||||
};
|
||||
|
||||
# Q&A API systemd service
|
||||
systemd.services.jetpham-qa-api = {
|
||||
description = "Jet Pham Q&A API";
|
||||
after = [ "network-online.target" ] ++ lib.optional usingDefaultWebhookSecret "agenix.service";
|
||||
wants = [ "network-online.target" ] ++ lib.optional usingDefaultWebhookSecret "agenix.service";
|
||||
after = [ "network.target" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig = {
|
||||
DynamicUser = true;
|
||||
StateDirectory = "jetpham-qa";
|
||||
WorkingDirectory = "/var/lib/jetpham-qa";
|
||||
Environment = [
|
||||
"QA_DB_PATH=/var/lib/jetpham-qa/qa.db"
|
||||
"QA_NOTIFY_EMAIL=${cfg.qaNotifyEmail}"
|
||||
"QA_MAIL_DOMAIN=${cfg.qaMailDomain}"
|
||||
"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";
|
||||
RestartSec = 5;
|
||||
LoadCredential = "webhook-secret:${webhookSecretPath}";
|
||||
|
|
@ -310,16 +135,46 @@ in
|
|||
'';
|
||||
};
|
||||
|
||||
services.caddy.virtualHosts.${cfg.domain} = lib.mkIf cfg.caddy.enable {
|
||||
extraConfig = caddyCommonConfig;
|
||||
services.caddy.virtualHosts.${cfg.domain} = {
|
||||
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}" =
|
||||
lib.mkIf (cfg.caddy.enable && cfg.tor.enable)
|
||||
{
|
||||
services.caddy.virtualHosts."http://:8888" = lib.mkIf cfg.tor.enable {
|
||||
extraConfig = ''
|
||||
bind 127.0.0.1
|
||||
${caddyCommonConfig}
|
||||
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
|
||||
}
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
|
|
|||
7895
package-lock.json
generated
7895
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,tsx,js,jsx,mdx}\" --cache",
|
||||
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||
"format:check": "prettier --check \"**/*.{ts,js,jsx,mdx}\" --cache",
|
||||
"format:write": "prettier --write \"**/*.{ts,js,jsx,mdx}\" --cache",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"preview": "vite preview",
|
||||
|
|
@ -17,8 +17,6 @@
|
|||
"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",
|
||||
|
|
@ -27,15 +25,8 @@
|
|||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vite": "^7.3.1"
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-singlefile": "^2.3.0"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
"knip": {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -1 +0,0 @@
|
|||
Hi! I hope that you have a great day. Thank you for visiting my site!
|
||||
|
|
@ -1 +0,0 @@
|
|||
go fuck yourself
|
||||
|
|
@ -6,9 +6,9 @@
|
|||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://jetpham.com/colophon</loc>
|
||||
<loc>https://jetpham.com/projects</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://jetpham.com/qa</loc>
|
||||
|
|
|
|||
97
server.mjs
97
server.mjs
|
|
@ -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}`);
|
||||
});
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
import { StartClient } from "@tanstack/react-start/client";
|
||||
import { hydrateRoot } from "react-dom/client";
|
||||
|
||||
hydrateRoot(document, <StartClient />);
|
||||
10
src/components/frosted-box.ts
Normal file
10
src/components/frosted-box.ts
Normal 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>`;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
>
|
||||
</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
Normal file
132
src/lib/api.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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
46
src/lib/site.ts
Normal 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
15
src/main.ts
Normal 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
67
src/pages/home.ts
Normal 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
13
src/pages/not-found.ts
Normal 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
283
src/pages/qa.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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
95
src/router.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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>>;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
@ -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&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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import { createStart } from "@tanstack/react-start";
|
||||
|
||||
export const startInstance = createStart(() => ({
|
||||
defaultSsr: true,
|
||||
}));
|
||||
|
|
@ -140,18 +140,12 @@ body[data-background-mode="failed"]::before {
|
|||
}
|
||||
|
||||
.site-shell {
|
||||
width: 100%;
|
||||
width: min(100%, 60%);
|
||||
box-sizing: border-box;
|
||||
margin: 0 auto;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.site-shell {
|
||||
width: min(100%, 60%);
|
||||
}
|
||||
}
|
||||
|
||||
.site-panel-frame {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
|
@ -159,7 +153,7 @@ body[data-background-mode="failed"]::before {
|
|||
|
||||
.site-panel-frost {
|
||||
position: absolute;
|
||||
inset: var(--panel-border-inset);
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
background:
|
||||
|
|
@ -171,6 +165,40 @@ body[data-background-mode="failed"]::before {
|
|||
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 {
|
||||
position: absolute;
|
||||
inset: var(--panel-border-inset);
|
||||
|
|
@ -221,36 +249,6 @@ a[aria-current="page"] {
|
|||
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 {
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
|
|
|
|||
252
tor.har
Normal file
252
tor.har
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,6 @@
|
|||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
import { defineConfig } from "vite";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
|
||||
import { viteSingleFile } from "vite-plugin-singlefile";
|
||||
import ansi from "./vite-plugin-ansi";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tanstackStart(), ansi(), tailwindcss(), react()],
|
||||
plugins: [
|
||||
ansi(),
|
||||
tailwindcss(),
|
||||
viteSingleFile({ useRecommendedBuildConfig: false }),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
"~": "/src",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue