feat: rewrite readme and clean up support
This commit is contained in:
parent
38af26d959
commit
7758be92b4
16 changed files with 383 additions and 453 deletions
98
README.md
98
README.md
|
|
@ -1,90 +1,72 @@
|
|||
# jetpham.com
|
||||
|
||||
<div align="center">
|
||||
<img src="src/app/icon0.svg" alt="jetpham.com icon" width="200" height="200">
|
||||
</div>
|
||||
Personal site for Jet Pham.
|
||||
|
||||
Jet Pham's personal website. This website comes with a long story. The domain was originally registered in highschool by my teamate on my robotics team as a joke. The site was originally a filesystem full of memes and random files. Once I was in college, the domain expired and I registered it myself.
|
||||
The site is a small Vite app with a terminal-style UI, ANSI-rendered text, and a Rust/WebAssembly Conway's Game of Life background.
|
||||
|
||||
The site originally contained a blog. It was made in Next.js plainly with plain colors and no real style. I posted a few blogs about my life but eventually lost motivaiton and didn't like sharing it with other people after having been inspired by so many other cool websites.
|
||||
## Features
|
||||
|
||||
I started to become more obsessed with Rust and rewrote my website from being a blog into a static linktree site made in rust via WASM. It was in ASCII style using a modified fork of ratzilla and had a fun implementation of Conways Game of Life in the background.
|
||||
- ASCII/ANSI-inspired visual style with the IBM VGA font
|
||||
- Conway's Game of Life running in the background via Rust + WebAssembly
|
||||
- Q+A page backed by the site API
|
||||
- Single-file oriented frontend build with Vite
|
||||
- Reduced-motion aware background controls with a static fallback mode
|
||||
|
||||
After leaving that website alone, I started to make more web based projects in Next.js. I realized I could properly make this website awesome and still keep the interesting style in the site while making it more performant, responsive, and accessible. This is the state that you see the website in now.
|
||||
## Stack
|
||||
|
||||
I have some awesome features packed in this site now that represent all the cool things I'm interested in:
|
||||
|
||||
- ANSI rendering of my name in CSS!
|
||||
- Terminal style text, font, and colors just like BBS
|
||||
- Rust WASM implementation of Conway's Game of Life running in the background
|
||||
- List of socials and contact info
|
||||
|
||||
Let me know if you have any feedback about the site!
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- [Next.js 16](https://nextjs.org) with Turbo mode
|
||||
- [Tailwind CSS v4](https://tailwindcss.com)
|
||||
- [TypeScript](https://www.typescriptlang.org/)
|
||||
- [React 19](https://react.dev/)
|
||||
- Rust + WebAssembly (for Conway's Game of Life)
|
||||
- [Bun](https://bun.sh) (package manager)
|
||||
- Vite
|
||||
- TypeScript
|
||||
- Tailwind CSS v4
|
||||
- Rust + WebAssembly
|
||||
- npm
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Bun
|
||||
- Rust (for building the Conway's Game of Life WASM module)
|
||||
- wasm-pack (or use the install script)
|
||||
- Node.js + npm
|
||||
- Rust
|
||||
- `wasm-pack`
|
||||
- `wasm-opt` (used by `build:wasm`)
|
||||
|
||||
### Getting Started
|
||||
|
||||
1. Clone the repository
|
||||
|
||||
2. Build the Rust WASM module:
|
||||
### Install
|
||||
|
||||
```bash
|
||||
bun run build:wasm
|
||||
npm install
|
||||
```
|
||||
|
||||
Or use the install script:
|
||||
### Build the WASM package
|
||||
|
||||
```bash
|
||||
./install.sh
|
||||
npm run build:wasm
|
||||
```
|
||||
|
||||
3. Install dependencies:
|
||||
### Start the dev server
|
||||
|
||||
```bash
|
||||
bun install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
4. Start the development server:
|
||||
### Check the app
|
||||
|
||||
```bash
|
||||
bun run dev
|
||||
npm run check
|
||||
```
|
||||
|
||||
The site will be available at `http://localhost:3000`.
|
||||
|
||||
## Q&A Mail Safety
|
||||
|
||||
The Q&A feature sends notification emails from `qa@...` and uses a static `Reply-To` like `qa@jetpham.com`. Replies are matched back to the right question by parsing the question number from the email subject, e.g. `123 - ...`.
|
||||
|
||||
To avoid breaking normal inbound mail when the Q&A API is down:
|
||||
|
||||
- set `services.jetpham-website.qaMailDomain = "jetpham.com";`
|
||||
- set `services.jetpham-website.qaReplyDomain = "jetpham.com";`
|
||||
- route only the exact reply address `qa@jetpham.com` into the Q&A webhook
|
||||
- keep your personal mail domain (`extremist.software`) delivering normally without depending on the Q&A webhook
|
||||
|
||||
This isolates the automation path from your main inbox. If the Q&A API fails, Q&A replies may be delayed, but personal mail should still deliver normally.
|
||||
|
||||
## Project Structure
|
||||
### Build for production
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
src/ - Next.js app router pages
|
||||
cgol/ - Rust WASM module for Conway's Game of Life
|
||||
public/ - Static assets
|
||||
|
||||
## Structure
|
||||
|
||||
```text
|
||||
src/ frontend app
|
||||
cgol/ Rust + WASM Conway's Game of Life module
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The homepage and Q+A page are the intended public pages.
|
||||
- If WebAssembly fails or motion is disabled, the site falls back to a static background treatment.
|
||||
|
|
|
|||
162
cgol/src/lib.rs
162
cgol/src/lib.rs
|
|
@ -1,5 +1,6 @@
|
|||
mod utils;
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
|
@ -12,13 +13,18 @@ use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement, ImageData, Window};
|
|||
const CELL_SIZE: u32 = 10;
|
||||
const TICK_MS: f64 = 1000.0 / 60.0;
|
||||
const HUE_PERIOD_MS: f64 = 3000.0;
|
||||
const STILL_STEPS: u32 = 5;
|
||||
|
||||
/// Cells: 0 = dead, 1-255 = alive with that hue value.
|
||||
const DEAD: u8 = 0;
|
||||
|
||||
#[inline(always)]
|
||||
fn safe_hue(h: u8) -> u8 {
|
||||
if h == DEAD { 1 } else { h }
|
||||
if h == DEAD {
|
||||
1
|
||||
} else {
|
||||
h
|
||||
}
|
||||
}
|
||||
|
||||
// ── Lookup tables (computed once) ──────────────────────────────────
|
||||
|
|
@ -66,7 +72,9 @@ impl Luts {
|
|||
s += self.sin[h as usize];
|
||||
c += self.cos[h as usize];
|
||||
}
|
||||
safe_hue((s.atan2(c).rem_euclid(std::f32::consts::TAU) * 256.0 / std::f32::consts::TAU) as u8)
|
||||
safe_hue(
|
||||
(s.atan2(c).rem_euclid(std::f32::consts::TAU) * 256.0 / std::f32::consts::TAU) as u8,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -136,7 +144,11 @@ impl Grid {
|
|||
let alive = cell != DEAD;
|
||||
|
||||
self.buf[i] = if alive {
|
||||
if count == 2 || count == 3 { cell } else { DEAD }
|
||||
if count == 2 || count == 3 {
|
||||
cell
|
||||
} else {
|
||||
DEAD
|
||||
}
|
||||
} else if count == 3 {
|
||||
// Only collect hues for births
|
||||
self.hues.clear();
|
||||
|
|
@ -185,6 +197,13 @@ struct App {
|
|||
dirty: bool,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static APP_STATE: RefCell<Option<Rc<RefCell<App>>>> = const { RefCell::new(None) };
|
||||
static RUNNING: Cell<bool> = const { Cell::new(false) };
|
||||
static LISTENERS_READY: Cell<bool> = const { Cell::new(false) };
|
||||
static RAF_CLOSURE: RefCell<Option<Closure<dyn FnMut()>>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn new() -> Result<Self, JsValue> {
|
||||
utils::set_panic_hook();
|
||||
|
|
@ -194,7 +213,8 @@ impl App {
|
|||
.get_element_by_id("canvas")
|
||||
.ok_or("no canvas")?
|
||||
.dyn_into()?;
|
||||
let ctx: CanvasRenderingContext2d = canvas.get_context("2d")?.ok_or("no 2d ctx")?.dyn_into()?;
|
||||
let ctx: CanvasRenderingContext2d =
|
||||
canvas.get_context("2d")?.ok_or("no 2d ctx")?.dyn_into()?;
|
||||
ctx.set_image_smoothing_enabled(false);
|
||||
|
||||
let mut app = Self {
|
||||
|
|
@ -261,26 +281,53 @@ impl App {
|
|||
px[i * 4..i * 4 + 4].copy_from_slice(&rgb[cell as usize]);
|
||||
}
|
||||
|
||||
if let Ok(img) =
|
||||
ImageData::new_with_u8_clamped_array_and_sh(Clamped(px), self.cw, self.ch)
|
||||
if let Ok(img) = ImageData::new_with_u8_clamped_array_and_sh(Clamped(px), self.cw, self.ch)
|
||||
{
|
||||
self.ctx.put_image_data(&img, 0.0, 0.0).ok();
|
||||
}
|
||||
}
|
||||
|
||||
fn render_steps(&mut self, steps: u32) {
|
||||
for _ in 0..steps {
|
||||
let luts = &self.luts as *const Luts;
|
||||
// SAFETY: luts is not mutated during tick()
|
||||
self.grid.tick(unsafe { &*luts });
|
||||
}
|
||||
self.dirty = true;
|
||||
self.draw();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Entry point ────────────────────────────────────────────────────
|
||||
fn app() -> Result<Rc<RefCell<App>>, JsValue> {
|
||||
APP_STATE.with(|state| {
|
||||
if let Some(app) = state.borrow().as_ref() {
|
||||
return Ok(app.clone());
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn start() -> Result<(), JsValue> {
|
||||
let app = App::new()?;
|
||||
let rc = Rc::new(RefCell::new(app));
|
||||
let app = Rc::new(RefCell::new(App::new()?));
|
||||
state.borrow_mut().replace(app.clone());
|
||||
Ok(app)
|
||||
})
|
||||
}
|
||||
|
||||
let win = rc.borrow().win.clone();
|
||||
fn init_listeners(app: Rc<RefCell<App>>) -> Result<(), JsValue> {
|
||||
let already_ready = LISTENERS_READY.with(|ready| {
|
||||
if ready.get() {
|
||||
true
|
||||
} else {
|
||||
ready.set(true);
|
||||
false
|
||||
}
|
||||
});
|
||||
|
||||
if already_ready {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let win = app.borrow().win.clone();
|
||||
let doc = win.document().unwrap();
|
||||
|
||||
// Mouse
|
||||
let s = rc.clone();
|
||||
let s = app.clone();
|
||||
let cb = Closure::wrap(Box::new(move |e: web_sys::MouseEvent| {
|
||||
let mut a = s.borrow_mut();
|
||||
a.ccol = e.client_x() / CELL_SIZE as i32;
|
||||
|
|
@ -290,8 +337,7 @@ pub fn start() -> Result<(), JsValue> {
|
|||
doc.add_event_listener_with_callback("mousemove", cb.as_ref().unchecked_ref())?;
|
||||
cb.forget();
|
||||
|
||||
// Touch move
|
||||
let s = rc.clone();
|
||||
let s = app.clone();
|
||||
let cb = Closure::wrap(Box::new(move |e: web_sys::TouchEvent| {
|
||||
e.prevent_default();
|
||||
if let Some(t) = e.touches().get(0) {
|
||||
|
|
@ -304,8 +350,7 @@ pub fn start() -> Result<(), JsValue> {
|
|||
doc.add_event_listener_with_callback("touchmove", cb.as_ref().unchecked_ref())?;
|
||||
cb.forget();
|
||||
|
||||
// Touch start
|
||||
let s = rc.clone();
|
||||
let s = app.clone();
|
||||
let cb = Closure::wrap(Box::new(move |e: web_sys::TouchEvent| {
|
||||
e.prevent_default();
|
||||
if let Some(t) = e.touches().get(0) {
|
||||
|
|
@ -318,29 +363,46 @@ pub fn start() -> Result<(), JsValue> {
|
|||
doc.add_event_listener_with_callback("touchstart", cb.as_ref().unchecked_ref())?;
|
||||
cb.forget();
|
||||
|
||||
// Touch end
|
||||
let s = rc.clone();
|
||||
let s = app.clone();
|
||||
let cb = Closure::wrap(Box::new(move |_: web_sys::TouchEvent| {
|
||||
s.borrow_mut().cursor_on = false;
|
||||
}) as Box<dyn FnMut(_)>);
|
||||
doc.add_event_listener_with_callback("touchend", cb.as_ref().unchecked_ref())?;
|
||||
cb.forget();
|
||||
|
||||
// Resize
|
||||
let s = rc.clone();
|
||||
let s = app;
|
||||
let cb = Closure::wrap(Box::new(move || {
|
||||
s.borrow_mut().resize();
|
||||
let mut a = s.borrow_mut();
|
||||
a.resize();
|
||||
if RUNNING.with(|running| running.get()) {
|
||||
a.draw();
|
||||
} else {
|
||||
a.render_steps(STILL_STEPS);
|
||||
}
|
||||
}) as Box<dyn FnMut()>);
|
||||
win.add_event_listener_with_callback("resize", cb.as_ref().unchecked_ref())?;
|
||||
cb.forget();
|
||||
|
||||
// Animation loop
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_animation_loop(app: Rc<RefCell<App>>) {
|
||||
RAF_CLOSURE.with(|slot| {
|
||||
if slot.borrow().is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let win = app.borrow().win.clone();
|
||||
let f: Rc<RefCell<Option<Closure<dyn FnMut()>>>> = Rc::new(RefCell::new(None));
|
||||
let g = f.clone();
|
||||
let s = rc.clone();
|
||||
let s = app.clone();
|
||||
let w = win.clone();
|
||||
|
||||
*g.borrow_mut() = Some(Closure::wrap(Box::new(move || {
|
||||
if !RUNNING.with(|running| running.get()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let now = s.borrow().now();
|
||||
|
||||
{
|
||||
|
|
@ -364,10 +426,58 @@ pub fn start() -> Result<(), JsValue> {
|
|||
a.draw();
|
||||
}
|
||||
|
||||
if RUNNING.with(|running| running.get()) {
|
||||
w.request_animation_frame(f.borrow().as_ref().unwrap().as_ref().unchecked_ref())
|
||||
.ok();
|
||||
}
|
||||
}) as Box<dyn FnMut()>));
|
||||
|
||||
win.request_animation_frame(g.borrow().as_ref().unwrap().as_ref().unchecked_ref())?;
|
||||
slot.borrow_mut().replace(g.borrow_mut().take().unwrap());
|
||||
});
|
||||
}
|
||||
|
||||
// ── Entry point ────────────────────────────────────────────────────
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn start() -> Result<(), JsValue> {
|
||||
let app = app()?;
|
||||
init_listeners(app.clone())?;
|
||||
|
||||
if RUNNING.with(|running| running.get()) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
RUNNING.with(|running| running.set(true));
|
||||
ensure_animation_loop(app.clone());
|
||||
|
||||
RAF_CLOSURE.with(|slot| {
|
||||
if let Some(cb) = slot.borrow().as_ref() {
|
||||
app.borrow()
|
||||
.win
|
||||
.request_animation_frame(cb.as_ref().unchecked_ref())
|
||||
} else {
|
||||
Err(JsValue::from_str("no animation closure"))
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn stop() -> Result<(), JsValue> {
|
||||
let _ = app()?;
|
||||
RUNNING.with(|running| running.set(false));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn render_still(steps: u32) -> Result<(), JsValue> {
|
||||
let app = app()?;
|
||||
init_listeners(app.clone())?;
|
||||
RUNNING.with(|running| running.set(false));
|
||||
let steps = steps.max(1);
|
||||
let mut app = app.borrow_mut();
|
||||
app.resize();
|
||||
app.render_steps(steps);
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
142
package-lock.json
generated
142
package-lock.json
generated
|
|
@ -16,8 +16,6 @@
|
|||
"anser": "^2.3.5",
|
||||
"escape-carriage": "^1.3.1",
|
||||
"eslint": "^10",
|
||||
"gray-matter": "^4.0.3",
|
||||
"marked": "^15.0.12",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"tailwindcss": "^4.2.1",
|
||||
|
|
@ -1595,7 +1593,6 @@
|
|||
"integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
|
|
@ -1645,7 +1642,6 @@
|
|||
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.56.1",
|
||||
"@typescript-eslint/types": "8.56.1",
|
||||
|
|
@ -1837,7 +1833,6 @@
|
|||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
|
|
@ -1879,16 +1874,6 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sprintf-js": "~1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
|
|
@ -2061,7 +2046,6 @@
|
|||
"integrity": "sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
|
|
@ -2162,20 +2146,6 @@
|
|||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/esprima": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
|
||||
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"bin": {
|
||||
"esparse": "bin/esparse.js",
|
||||
"esvalidate": "bin/esvalidate.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/esquery": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
|
||||
|
|
@ -2222,19 +2192,6 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/extend-shallow": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
|
||||
"integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-extendable": "^0.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
|
|
@ -2373,22 +2330,6 @@
|
|||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/gray-matter": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz",
|
||||
"integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-yaml": "^3.13.1",
|
||||
"kind-of": "^6.0.2",
|
||||
"section-matter": "^1.0.0",
|
||||
"strip-bom-string": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
|
|
@ -2409,16 +2350,6 @@
|
|||
"node": ">=0.8.19"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extendable": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
|
||||
"integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
|
|
@ -2469,20 +2400,6 @@
|
|||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "3.14.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
|
||||
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^1.0.7",
|
||||
"esprima": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/json-buffer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
||||
|
|
@ -2514,16 +2431,6 @@
|
|||
"json-buffer": "3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/kind-of": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
|
||||
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/levn": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||
|
|
@ -2825,19 +2732,6 @@
|
|||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "15.0.12",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz",
|
||||
"integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/micromatch": {
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||
|
|
@ -2997,7 +2891,6 @@
|
|||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
|
@ -3050,7 +2943,6 @@
|
|||
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
|
|
@ -3156,7 +3048,6 @@
|
|||
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
|
|
@ -3196,20 +3087,6 @@
|
|||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/section-matter": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
|
||||
"integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"extend-shallow": "^2.0.1",
|
||||
"kind-of": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
|
|
@ -3256,23 +3133,6 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sprintf-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/strip-bom-string": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz",
|
||||
"integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz",
|
||||
|
|
@ -3364,7 +3224,6 @@
|
|||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
|
@ -3434,7 +3293,6 @@
|
|||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
|
|
|
|||
|
|
@ -24,8 +24,6 @@
|
|||
"anser": "^2.3.5",
|
||||
"escape-carriage": "^1.3.1",
|
||||
"eslint": "^10",
|
||||
"gray-matter": "^4.0.3",
|
||||
"marked": "^15.0.12",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"tailwindcss": "^4.2.1",
|
||||
|
|
|
|||
|
|
@ -1,10 +0,0 @@
|
|||
---
|
||||
title: Conway's Game of Life
|
||||
description: WebAssembly implementation of Conway's Game of Life, running as the background of this website.
|
||||
---
|
||||
|
||||
The background animation on this site is a WebAssembly implementation of
|
||||
Conway's Game of Life, written in Rust and compiled to WASM.
|
||||
|
||||
It runs directly in your browser using the HTML5 Canvas API, simulating
|
||||
cellular automata in real-time.
|
||||
20
src/global.d.ts
vendored
20
src/global.d.ts
vendored
|
|
@ -14,23 +14,3 @@ declare module "*.utf8ans?raw" {
|
|||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module "virtual:projects" {
|
||||
interface Project {
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
html: string;
|
||||
}
|
||||
const projects: Project[];
|
||||
export default projects;
|
||||
}
|
||||
|
||||
declare module "gray-matter" {
|
||||
interface GrayMatterResult {
|
||||
data: Record<string, string>;
|
||||
content: string;
|
||||
}
|
||||
function matter(input: string): GrayMatterResult;
|
||||
export = matter;
|
||||
}
|
||||
|
|
|
|||
115
src/lib/background.ts
Normal file
115
src/lib/background.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
const STORAGE_KEY = "background-motion-preference";
|
||||
const MOTION_QUERY = "(prefers-reduced-motion: reduce)";
|
||||
const STILL_STEPS = 5;
|
||||
|
||||
type MotionPreference = "auto" | "off" | "on";
|
||||
type BackgroundMode = "animated" | "still" | "failed";
|
||||
|
||||
interface BackgroundActions {
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
renderStill: (steps: number) => void;
|
||||
}
|
||||
|
||||
function readPreference(): MotionPreference {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
return stored === "off" || stored === "on" || stored === "auto"
|
||||
? stored
|
||||
: "auto";
|
||||
}
|
||||
|
||||
function writePreference(preference: MotionPreference) {
|
||||
window.localStorage.setItem(STORAGE_KEY, preference);
|
||||
}
|
||||
|
||||
function getMode(
|
||||
preference: MotionPreference,
|
||||
reducedMotion: boolean,
|
||||
): BackgroundMode {
|
||||
if (preference === "on") return "animated";
|
||||
if (preference === "off") return "still";
|
||||
return reducedMotion ? "still" : "animated";
|
||||
}
|
||||
|
||||
function applyCanvasState(mode: BackgroundMode) {
|
||||
const canvas = document.getElementById("canvas");
|
||||
document.body.dataset.backgroundMode = mode;
|
||||
if (canvas) {
|
||||
canvas.toggleAttribute("hidden", mode === "failed");
|
||||
}
|
||||
}
|
||||
|
||||
function updateControls(
|
||||
preference: MotionPreference,
|
||||
mode: BackgroundMode,
|
||||
reducedMotion: boolean,
|
||||
) {
|
||||
const button = document.getElementById(
|
||||
"background-toggle",
|
||||
) as HTMLButtonElement | null;
|
||||
const status = document.getElementById("background-status");
|
||||
if (!button || !status) return;
|
||||
|
||||
button.textContent = `motion ${preference}`;
|
||||
|
||||
if (mode === "failed") {
|
||||
status.textContent = "background unavailable";
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === "still") {
|
||||
status.textContent =
|
||||
preference === "auto" && reducedMotion
|
||||
? "still frame"
|
||||
: "background still";
|
||||
return;
|
||||
}
|
||||
|
||||
status.textContent = "background live";
|
||||
}
|
||||
|
||||
export function initBackgroundControls(actions: BackgroundActions) {
|
||||
const media = window.matchMedia(MOTION_QUERY);
|
||||
let preference = readPreference();
|
||||
let mode: BackgroundMode = getMode(preference, media.matches);
|
||||
|
||||
const applyMode = () => {
|
||||
mode = getMode(preference, media.matches);
|
||||
applyCanvasState(mode);
|
||||
updateControls(preference, mode, media.matches);
|
||||
|
||||
if (mode === "animated") {
|
||||
actions.start();
|
||||
return;
|
||||
}
|
||||
|
||||
actions.stop();
|
||||
actions.renderStill(STILL_STEPS);
|
||||
};
|
||||
|
||||
const button = document.getElementById(
|
||||
"background-toggle",
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
button?.addEventListener("click", () => {
|
||||
preference =
|
||||
preference === "auto" ? "off" : preference === "off" ? "on" : "auto";
|
||||
writePreference(preference);
|
||||
applyMode();
|
||||
});
|
||||
|
||||
media.addEventListener("change", () => {
|
||||
applyMode();
|
||||
});
|
||||
|
||||
return {
|
||||
applyInitialMode() {
|
||||
applyMode();
|
||||
},
|
||||
setFailed() {
|
||||
mode = "failed";
|
||||
applyCanvasState(mode);
|
||||
updateControls(preference, mode, media.matches);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -38,6 +38,9 @@ export function renderFooter() {
|
|||
<span aria-hidden="true">|</span>
|
||||
<a href="/ssh.txt" data-native-link>ssh</a>
|
||||
<span aria-hidden="true">|</span>
|
||||
<button type="button" id="background-toggle" class="qa-inline-action">motion auto</button>
|
||||
<span id="background-status" class="qa-meta" aria-live="polite"></span>
|
||||
<span aria-hidden="true">|</span>
|
||||
<a href="${mirror.href}">${mirror.label}</a>
|
||||
</div>
|
||||
</div>`;
|
||||
|
|
|
|||
20
src/main.ts
20
src/main.ts
|
|
@ -1,6 +1,7 @@
|
|||
import "~/styles/globals.css";
|
||||
import init, { start } from "cgol";
|
||||
import init, { render_still, start, stop } from "cgol";
|
||||
import { route, initRouter } from "~/router";
|
||||
import { initBackgroundControls } from "~/lib/background";
|
||||
import { renderFooter } from "~/lib/site";
|
||||
import { homePage } from "~/pages/home";
|
||||
import { qaPage } from "~/pages/qa";
|
||||
|
|
@ -10,12 +11,25 @@ route("/", "Jet Pham - Home", homePage);
|
|||
route("/qa", "Jet Pham - Q+A", qaPage);
|
||||
route("*", "404 - Jet Pham", notFoundPage);
|
||||
|
||||
renderFooter();
|
||||
const background = initBackgroundControls({
|
||||
start() {
|
||||
start();
|
||||
},
|
||||
stop() {
|
||||
stop();
|
||||
},
|
||||
renderStill(steps) {
|
||||
render_still(steps);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await init();
|
||||
start();
|
||||
background.applyInitialMode();
|
||||
} catch (e) {
|
||||
background.setFailed();
|
||||
console.error("WASM init failed:", e);
|
||||
}
|
||||
|
||||
initRouter();
|
||||
renderFooter();
|
||||
|
|
|
|||
|
|
@ -46,7 +46,8 @@ export function homePage(outlet: HTMLElement) {
|
|||
) as HTMLSpanElement;
|
||||
let resetTimer: number | null = null;
|
||||
|
||||
copyButton.addEventListener("click", async () => {
|
||||
copyButton.addEventListener("click", () => {
|
||||
void (async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText("jet@extremist.software");
|
||||
copyStatus.textContent = "copied";
|
||||
|
|
@ -61,5 +62,6 @@ export function homePage(outlet: HTMLElement) {
|
|||
copyStatus.textContent = "";
|
||||
resetTimer = null;
|
||||
}, 1400);
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,24 +0,0 @@
|
|||
import projects from "virtual:projects";
|
||||
import { frostedBox } from "~/components/frosted-box";
|
||||
|
||||
export function projectPage(outlet: HTMLElement, params: Record<string, string>) {
|
||||
const project = projects.find((p) => p.slug === params.slug);
|
||||
if (!project) {
|
||||
outlet.innerHTML = `
|
||||
<div class="flex flex-col items-center justify-start px-4">
|
||||
${frostedBox(`
|
||||
<h1 style="color: var(--light-red);">Project not found</h1>
|
||||
<p class="mt-[1ch]"><a href="/projects">[BACK TO PROJECTS]</a></p>
|
||||
`)}
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
outlet.innerHTML = `
|
||||
<div class="flex flex-col items-center justify-start px-4">
|
||||
${frostedBox(`
|
||||
<h1 style="color: var(--yellow);">${project.title}</h1>
|
||||
<div class="project-content mt-[2ch]">${project.html}</div>
|
||||
`)}
|
||||
</div>`;
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import projects from "virtual:projects";
|
||||
import { frostedBox } from "~/components/frosted-box";
|
||||
|
||||
export function projectsPage(outlet: HTMLElement) {
|
||||
const list = projects
|
||||
.map(
|
||||
(p) => `
|
||||
<li class="mb-[1ch]">
|
||||
<a href="/projects/${p.slug}" style="color: var(--light-cyan);">${p.title}</a>
|
||||
<p style="color: var(--light-gray);">${p.description}</p>
|
||||
</li>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
outlet.innerHTML = `
|
||||
<div class="flex flex-col items-center justify-start px-4">
|
||||
${frostedBox(`
|
||||
<h1 style="color: var(--yellow);">Projects</h1>
|
||||
<ul class="mt-[2ch]">${list}</ul>
|
||||
`)}
|
||||
</div>`;
|
||||
}
|
||||
|
|
@ -347,10 +347,15 @@ a[aria-current="page"] {
|
|||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1ch;
|
||||
color: var(--dark-gray);
|
||||
}
|
||||
|
||||
#background-status {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
|
|
@ -364,39 +369,3 @@ a[aria-current="page"] {
|
|||
}
|
||||
|
||||
/* Project markdown content */
|
||||
.project-content h2 {
|
||||
color: var(--light-cyan);
|
||||
margin-top: 2ch;
|
||||
margin-bottom: 1ch;
|
||||
}
|
||||
|
||||
.project-content h3 {
|
||||
color: var(--light-green);
|
||||
margin-top: 1.5ch;
|
||||
margin-bottom: 0.5ch;
|
||||
}
|
||||
|
||||
.project-content p {
|
||||
margin-bottom: 1ch;
|
||||
}
|
||||
|
||||
.project-content ul,
|
||||
.project-content ol {
|
||||
margin-left: 2ch;
|
||||
margin-bottom: 1ch;
|
||||
}
|
||||
|
||||
.project-content code {
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
.project-content pre {
|
||||
border: 2px solid var(--dark-gray);
|
||||
padding: 1ch;
|
||||
overflow-x: auto;
|
||||
margin-bottom: 1ch;
|
||||
}
|
||||
|
||||
.project-content a {
|
||||
color: var(--light-blue);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,20 +9,9 @@
|
|||
"skipLibCheck": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": [
|
||||
"./src/*"
|
||||
]
|
||||
"~/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
"vite.config.ts",
|
||||
"vite-plugin-ansi.ts",
|
||||
"vite-plugin-markdown.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"cgol/pkg"
|
||||
]
|
||||
"include": ["src", "vite.config.ts", "vite-plugin-ansi.ts"],
|
||||
"exclude": ["node_modules", "dist", "cgol/pkg"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,32 +0,0 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import matter from "gray-matter";
|
||||
import { marked } from "marked";
|
||||
import type { Plugin } from "vite";
|
||||
|
||||
export default function markdownPlugin(): Plugin {
|
||||
const virtualModuleId = "virtual:projects";
|
||||
const resolvedId = "\0" + virtualModuleId;
|
||||
const projectsDir = path.resolve("src/content/projects");
|
||||
|
||||
return {
|
||||
name: "vite-plugin-markdown",
|
||||
resolveId(id) {
|
||||
if (id === virtualModuleId) return resolvedId;
|
||||
},
|
||||
load(id) {
|
||||
if (id !== resolvedId) return;
|
||||
|
||||
const files = fs.readdirSync(projectsDir).filter((f) => f.endsWith(".md"));
|
||||
const projects = files.map((file) => {
|
||||
const raw = fs.readFileSync(path.join(projectsDir, file), "utf-8");
|
||||
const { data, content } = matter(raw);
|
||||
const html = marked(content);
|
||||
const slug = file.replace(".md", "");
|
||||
return { slug, ...data, html };
|
||||
});
|
||||
|
||||
return `export default ${JSON.stringify(projects)};`;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -4,12 +4,10 @@ import wasm from "vite-plugin-wasm";
|
|||
import topLevelAwait from "vite-plugin-top-level-await";
|
||||
import { viteSingleFile } from "vite-plugin-singlefile";
|
||||
import ansi from "./vite-plugin-ansi";
|
||||
import markdown from "./vite-plugin-markdown";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
ansi(),
|
||||
markdown(),
|
||||
tailwindcss(),
|
||||
wasm(),
|
||||
topLevelAwait(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue