Compare commits
50 commits
vercel/rea
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 056daa6460 | |||
| 60c598c17d | |||
| 1174299e77 | |||
| 9a5256e8f0 | |||
| 17d708eb7a | |||
| 51dc6c9ee6 | |||
| 38efffa9b9 | |||
| 3f74df0b3a | |||
| 7758be92b4 | |||
| 38af26d959 | |||
| c55413c47f | |||
| 5356e2dbb4 | |||
| 3937d8fd75 | |||
| d76a44aa02 | |||
| 6ba64d29a9 | |||
| 691394445a | |||
| 1149597139 | |||
| 6a652ed4f3 | |||
| ede986080a | |||
| 55a862fabb | |||
| 78ac87a421 | |||
| add50dc7af | |||
| 2e9b32a8fd | |||
| 51bbf67bbe | |||
| 7b842b3342 | |||
|
|
f48390b15e | ||
|
|
99715f6105 | ||
|
|
2bc98414b1 | ||
|
|
44da77246d | ||
|
|
e25803a767 | ||
|
|
9905bf8759 | ||
|
|
a7ecb3a390 | ||
|
|
4c09d416cf | ||
|
|
5cbe032c23 | ||
|
|
e6a9b1a111 | ||
|
|
88c69d1f81 | ||
|
|
b3e15efb28 | ||
|
|
3e9fc81e02 | ||
|
|
e942fce4b4 | ||
|
|
56f8e05ae0 | ||
|
|
3364a6ae9b | ||
|
|
7b726be760 | ||
|
|
2ee196f43d | ||
|
|
e7b44ba112 | ||
|
|
bf5900edbb | ||
|
|
fc80ead81e | ||
|
|
afb117b42b | ||
|
|
a8212f744a | ||
|
|
ed3f252ea7 | ||
|
|
92bd962b14 |
14
.env.example
|
|
@ -1,14 +0,0 @@
|
|||
# Since the ".env" file is gitignored, you can use the ".env.example" file to
|
||||
# build a new ".env" file when you clone the repo. Keep this file up-to-date
|
||||
# when you add new variables to `.env`.
|
||||
|
||||
# This file will be committed to version control, so make sure not to have any
|
||||
# secrets in it. If you are cloning this repo, create a copy of this file named
|
||||
# ".env" and populate it with your secrets.
|
||||
|
||||
# When adding additional environment variables, the schema in "/src/env.js"
|
||||
# should be updated accordingly.
|
||||
|
||||
# Prisma
|
||||
# https://www.prisma.io/docs/reference/database-reference/connection-urls#env
|
||||
DATABASE_URL="postgresql://postgres:password@localhost:5432/website"
|
||||
35
.gitignore
vendored
|
|
@ -1,29 +1,16 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# database
|
||||
/prisma/db.sqlite
|
||||
/prisma/db.sqlite-journal
|
||||
db.sqlite
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
next-env.d.ts
|
||||
|
||||
# production
|
||||
/build
|
||||
# vite
|
||||
/dist
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
*.har
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
|
|
@ -31,14 +18,10 @@ yarn-debug.log*
|
|||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# local env files
|
||||
# do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables
|
||||
# env files
|
||||
.env
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
|
|
@ -46,4 +29,12 @@ yarn-error.log*
|
|||
.idea
|
||||
|
||||
/.direnv
|
||||
/generated
|
||||
result
|
||||
|
||||
# rust
|
||||
/api/target
|
||||
|
||||
# sqlite
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
|
|
|||
163
README.md
|
|
@ -1,103 +1,94 @@
|
|||
# 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 WebGL2 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.
|
||||
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.
|
||||
|
||||
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 ratatui and had a fun implementation of Conways Game of Life in the background.
|
||||
## Features
|
||||
|
||||
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.
|
||||
- ASCII/ANSI-inspired visual style with the IBM VGA font
|
||||
- Conway's Game of Life running in the background via WebGL2
|
||||
- Q+A page backed by the site API
|
||||
- Single-file oriented frontend build with Vite
|
||||
- Fullscreen GPU blur/composite for the frosted panel effect
|
||||
|
||||
I have some awesome features packed in this site now that represent all the cool things I'm interested in:
|
||||
## Stack
|
||||
|
||||
- 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 15](https://nextjs.org) with Turbo mode
|
||||
- [Prisma](https://prisma.io) with PostgreSQL
|
||||
- [Tailwind CSS v4](https://tailwindcss.com)
|
||||
- [tRPC](https://trpc.io)
|
||||
- [TypeScript](https://www.typescriptlang.org/)
|
||||
- [React Query](https://tanstack.com/query)
|
||||
- [React 19](https://react.dev/)
|
||||
- Rust + WebAssembly (for Conway's Game of Life)
|
||||
- [Bun](https://bun.sh) (package manager)
|
||||
- Vite
|
||||
- TypeScript
|
||||
- Tailwind CSS v4
|
||||
- WebGL2
|
||||
- npm
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Bun
|
||||
- Docker (or Podman)
|
||||
- Rust (for building the Conway's Game of Life WASM module)
|
||||
- wasm-pack (install via `curl https://drager.github.io/wasm-pack/installer/init.sh -sSf | sh` or use the install script)
|
||||
- Node.js + npm
|
||||
|
||||
### Getting Started
|
||||
|
||||
1. Clone the repository
|
||||
|
||||
2. Build the Rust WASM module:
|
||||
|
||||
```bash
|
||||
bun run build:wasm
|
||||
```
|
||||
|
||||
Or use the install script which will also install wasm-pack if needed:
|
||||
|
||||
```bash
|
||||
./install.sh
|
||||
```
|
||||
|
||||
3. Install dependencies:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
4. Set up environment variables:
|
||||
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
# Edit .env.local with your configuration
|
||||
```
|
||||
|
||||
Adjust the database URL as needed for your setup.
|
||||
|
||||
5. Start the database:
|
||||
|
||||
```bash
|
||||
./start-database.sh
|
||||
```
|
||||
|
||||
This script will start a PostgreSQL database in a Docker or Podman container. Make sure Docker or Podman is installed and running.
|
||||
|
||||
6. Set up the database schema:
|
||||
|
||||
```bash
|
||||
bun run db:push
|
||||
```
|
||||
|
||||
7. Start the development server:
|
||||
|
||||
```bash
|
||||
bun run dev
|
||||
```
|
||||
|
||||
The site will be available at `http://localhost:3000`.
|
||||
|
||||
## Project Structure
|
||||
### Install
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
src/ - Next.js app router pages
|
||||
cgol/ - Rust WASM module for Conway's Game of Life
|
||||
|
||||
### Start the dev server
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Check the app
|
||||
|
||||
```bash
|
||||
npm run check
|
||||
```
|
||||
|
||||
### Build for production
|
||||
|
||||
```bash
|
||||
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.
|
||||
- The background renderer targets WebGL2 and falls back to the site shell background if WebGL2 is unavailable.
|
||||
|
|
|
|||
1787
api/Cargo.lock
generated
Normal file
26
api/Cargo.toml
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
[package]
|
||||
name = "jetpham-qa-api"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
axum = "0.8"
|
||||
base64 = "0.22"
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
lettre = "0.11"
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tower-http = { version = "0.6", features = ["cors"] }
|
||||
|
||||
[profile.dev]
|
||||
debug = "line-tables-only"
|
||||
split-debuginfo = "unpacked"
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
debug = 0
|
||||
lto = "thin"
|
||||
panic = "abort"
|
||||
strip = "symbols"
|
||||
106
api/src/email.rs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
use lettre::message::Mailbox;
|
||||
use lettre::transport::smtp::client::Tls;
|
||||
use lettre::{Message, SmtpTransport, Transport};
|
||||
|
||||
pub fn send_notification(
|
||||
id: i64,
|
||||
question: &str,
|
||||
notify_email: &str,
|
||||
mail_domain: &str,
|
||||
reply_domain: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let truncated = if question.len() > 50 {
|
||||
format!("{}...", &question[..50])
|
||||
} else {
|
||||
question.to_string()
|
||||
};
|
||||
|
||||
let from: Mailbox = format!("Q&A <qa@{mail_domain}>").parse()?;
|
||||
let reply_to: Mailbox = format!("qa@{reply_domain}").parse()?;
|
||||
let to: Mailbox = notify_email.parse()?;
|
||||
|
||||
let email = Message::builder()
|
||||
.from(from)
|
||||
.reply_to(reply_to)
|
||||
.to(to)
|
||||
.subject(format!("{id} - {truncated}"))
|
||||
.body(question.to_string())?;
|
||||
|
||||
let mailer = SmtpTransport::builder_dangerous("localhost")
|
||||
.tls(Tls::None)
|
||||
.hello_name(lettre::transport::smtp::extension::ClientId::Domain(
|
||||
mail_domain.to_string(),
|
||||
))
|
||||
.build();
|
||||
mailer.send(&email)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn extract_id_from_subject(subject: &str) -> Result<i64, Box<dyn std::error::Error>> {
|
||||
let subject = subject.trim();
|
||||
let start = subject
|
||||
.char_indices()
|
||||
.find_map(|(idx, ch)| ch.is_ascii_digit().then_some(idx))
|
||||
.ok_or("Subject missing question id")?;
|
||||
let digits: String = subject[start..]
|
||||
.chars()
|
||||
.take_while(|c| c.is_ascii_digit())
|
||||
.collect();
|
||||
|
||||
if digits.is_empty() {
|
||||
return Err("Subject missing numeric question id".into());
|
||||
}
|
||||
|
||||
let remainder = subject[start + digits.len()..].trim_start();
|
||||
if !(remainder.starts_with('-') || remainder.starts_with(':')) {
|
||||
return Err("Subject missing separator after question id".into());
|
||||
}
|
||||
|
||||
Ok(digits.parse()?)
|
||||
}
|
||||
|
||||
pub fn extract_plain_text_body(contents: &str) -> String {
|
||||
let normalized = contents.replace("\r\n", "\n");
|
||||
let body = if let Some((headers, body)) = normalized.split_once("\n\n") {
|
||||
if headers.lines().any(|line| line.contains(':')) {
|
||||
body
|
||||
} else {
|
||||
normalized.as_str()
|
||||
}
|
||||
} else {
|
||||
normalized.as_str()
|
||||
};
|
||||
|
||||
strip_quoted_text(body)
|
||||
}
|
||||
|
||||
pub fn strip_quoted_text(body: &str) -> String {
|
||||
let mut result = Vec::new();
|
||||
for line in body.lines() {
|
||||
if line.starts_with('>') {
|
||||
continue;
|
||||
}
|
||||
if line.starts_with("On ") && line.ends_with("wrote:") {
|
||||
break;
|
||||
}
|
||||
result.push(line);
|
||||
}
|
||||
result.join("\n").trim().to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{extract_id_from_subject, extract_plain_text_body};
|
||||
|
||||
#[test]
|
||||
fn extracts_id_from_subject() {
|
||||
assert_eq!(extract_id_from_subject("Re: 42 - Hello").unwrap(), 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_plain_text_from_raw_email() {
|
||||
let raw = "Subject: Q&A #42: Hello\r\nFrom: Jet <jet@example.com>\r\n\r\nThis is the answer.\r\n\r\nOn earlier mail wrote:\r\n> quoted";
|
||||
assert_eq!(extract_plain_text_body(raw), "This is the answer.");
|
||||
}
|
||||
}
|
||||
692
api/src/handlers.rs
Normal file
|
|
@ -0,0 +1,692 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::http::header::CONTENT_TYPE;
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::Json;
|
||||
use base64::Engine;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::email;
|
||||
use crate::serve::AppState;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Question {
|
||||
id: i64,
|
||||
question: String,
|
||||
answer: String,
|
||||
created_at: String,
|
||||
answered_at: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct QuestionStats {
|
||||
asked: i64,
|
||||
answered: i64,
|
||||
}
|
||||
|
||||
const SITE_URL: &str = "https://jetpham.com";
|
||||
|
||||
fn xml_escape(text: &str) -> String {
|
||||
text.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
fn rss_pub_date(timestamp: &str) -> String {
|
||||
DateTime::parse_from_rfc3339(timestamp)
|
||||
.map(|dt| dt.to_rfc2822())
|
||||
.unwrap_or_else(|_| Utc::now().to_rfc2822())
|
||||
}
|
||||
|
||||
pub async fn get_questions(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<Json<Vec<Question>>, StatusCode> {
|
||||
let db = state
|
||||
.db
|
||||
.lock()
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let mut stmt = db
|
||||
.prepare(
|
||||
"SELECT id, question, answer, created_at, answered_at \
|
||||
FROM questions WHERE answer IS NOT NULL \
|
||||
ORDER BY answered_at DESC",
|
||||
)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let questions = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(Question {
|
||||
id: row.get(0)?,
|
||||
question: row.get(1)?,
|
||||
answer: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
answered_at: row.get(4)?,
|
||||
})
|
||||
})
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(Json(questions))
|
||||
}
|
||||
|
||||
pub async fn get_question_stats(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<Json<QuestionStats>, StatusCode> {
|
||||
let db = state
|
||||
.db
|
||||
.lock()
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let asked: i64 = db
|
||||
.query_row("SELECT COUNT(*) FROM questions", [], |row| row.get(0))
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let answered: i64 = db
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM questions WHERE answer IS NOT NULL",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(Json(QuestionStats { asked, answered }))
|
||||
}
|
||||
|
||||
pub async fn get_question_rss(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<impl IntoResponse, StatusCode> {
|
||||
let db = state
|
||||
.db
|
||||
.lock()
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let mut stmt = db
|
||||
.prepare(
|
||||
"SELECT id, question, answer, created_at, answered_at \
|
||||
FROM questions WHERE answer IS NOT NULL \
|
||||
ORDER BY answered_at DESC",
|
||||
)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let questions = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(Question {
|
||||
id: row.get(0)?,
|
||||
question: row.get(1)?,
|
||||
answer: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
answered_at: row.get(4)?,
|
||||
})
|
||||
})
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let items = questions
|
||||
.into_iter()
|
||||
.map(|question| {
|
||||
let guid = format!("{SITE_URL}/qa#question-{}", question.id);
|
||||
let description = format!(
|
||||
"Question: {}\n\nAnswer: {}",
|
||||
question.question, question.answer
|
||||
);
|
||||
|
||||
format!(
|
||||
"<item><title>{}</title><link>{}</link><guid>{}</guid><pubDate>{}</pubDate><description>{}</description></item>",
|
||||
xml_escape(&question.question),
|
||||
xml_escape(&guid),
|
||||
xml_escape(&guid),
|
||||
xml_escape(&rss_pub_date(&question.answered_at)),
|
||||
xml_escape(&description),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
|
||||
let xml = format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?><rss version=\"2.0\"><channel><title>Jet Pham Q+A</title><link>{SITE_URL}/qa</link><description>Answered questions from Jet Pham's site</description><language>en-us</language>{items}</channel></rss>"
|
||||
);
|
||||
|
||||
Ok(([(CONTENT_TYPE, "application/rss+xml; charset=utf-8")], xml))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SubmitQuestion {
|
||||
question: String,
|
||||
}
|
||||
|
||||
pub async fn post_question(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<SubmitQuestion>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
if body.question.is_empty() || body.question.len() > 200 {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Question must be 1-200 characters".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let ip = headers
|
||||
.get("x-forwarded-for")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.split(',').next())
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
if !state.rate_limiter.check(&ip) {
|
||||
return Err((
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"Too many questions. Try again later.".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let id: i64 = {
|
||||
let db = state
|
||||
.db
|
||||
.lock()
|
||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "db error".to_string()))?;
|
||||
db.execute(
|
||||
"INSERT INTO questions (question) VALUES (?1)",
|
||||
rusqlite::params![body.question],
|
||||
)
|
||||
.map_err(|_| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"insert error".to_string(),
|
||||
)
|
||||
})?;
|
||||
db.last_insert_rowid()
|
||||
};
|
||||
|
||||
let notify_email = state.notify_email.clone();
|
||||
let mail_domain = state.mail_domain.clone();
|
||||
let qa_reply_domain = state.qa_reply_domain.clone();
|
||||
let question_text = body.question.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
if let Err(e) = email::send_notification(
|
||||
id,
|
||||
&question_text,
|
||||
¬ify_email,
|
||||
&mail_domain,
|
||||
&qa_reply_domain,
|
||||
) {
|
||||
eprintln!("Failed to send notification: {e}");
|
||||
}
|
||||
});
|
||||
|
||||
Ok(StatusCode::CREATED)
|
||||
}
|
||||
|
||||
// --- MTA Hook webhook types ---
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct MtaHookPayload {
|
||||
#[serde(default)]
|
||||
pub messages: Vec<MtaHookMessage>,
|
||||
#[serde(default)]
|
||||
pub envelope: Envelope,
|
||||
#[serde(default)]
|
||||
pub message: MtaHookBody,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct MtaHookMessage {
|
||||
#[serde(default)]
|
||||
pub envelope: Envelope,
|
||||
#[serde(default)]
|
||||
pub message: MtaHookBody,
|
||||
#[serde(default)]
|
||||
pub contents: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
pub struct Envelope {
|
||||
#[serde(default)]
|
||||
pub to: Vec<Recipient>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum Recipient {
|
||||
Address(String),
|
||||
WithAddress { address: String },
|
||||
}
|
||||
|
||||
impl Default for Recipient {
|
||||
fn default() -> Self {
|
||||
Self::Address(String::new())
|
||||
}
|
||||
}
|
||||
|
||||
impl Recipient {
|
||||
fn address(&self) -> &str {
|
||||
match self {
|
||||
Self::Address(address) => address,
|
||||
Self::WithAddress { address } => address,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
pub struct MtaHookBody {
|
||||
#[serde(default)]
|
||||
pub subject: Option<String>,
|
||||
#[serde(default)]
|
||||
pub headers: MessageHeaders,
|
||||
#[serde(default)]
|
||||
pub contents: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
pub struct MessageHeaders {
|
||||
#[serde(default)]
|
||||
pub subject: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct MtaHookResponse {
|
||||
pub action: &'static str,
|
||||
}
|
||||
|
||||
fn webhook_secret_matches(headers: &HeaderMap, expected_secret: &str) -> bool {
|
||||
let expected_secret = expected_secret.trim();
|
||||
let header_secret = headers
|
||||
.get("X-Webhook-Secret")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
if header_secret == expected_secret {
|
||||
return true;
|
||||
}
|
||||
|
||||
let auth_header = match headers.get(axum::http::header::AUTHORIZATION) {
|
||||
Some(value) => value,
|
||||
None => return false,
|
||||
};
|
||||
let auth_header = match auth_header.to_str() {
|
||||
Ok(value) => value,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let encoded = match auth_header.strip_prefix("Basic ") {
|
||||
Some(value) => value,
|
||||
None => return false,
|
||||
};
|
||||
let decoded = match base64::engine::general_purpose::STANDARD.decode(encoded) {
|
||||
Ok(value) => value,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let credentials = match std::str::from_utf8(&decoded) {
|
||||
Ok(value) => value,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let (_, password) = match credentials.split_once(':') {
|
||||
Some(parts) => parts,
|
||||
None => return false,
|
||||
};
|
||||
|
||||
password.trim() == expected_secret
|
||||
}
|
||||
|
||||
fn webhook_secret_debug(headers: &HeaderMap) -> String {
|
||||
let header_secret = headers
|
||||
.get("X-Webhook-Secret")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
|
||||
let auth = headers
|
||||
.get(axum::http::header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
|
||||
let decoded = auth
|
||||
.strip_prefix("Basic ")
|
||||
.and_then(|encoded| base64::engine::general_purpose::STANDARD.decode(encoded).ok())
|
||||
.and_then(|bytes| String::from_utf8(bytes).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
format!(
|
||||
"x-webhook-secret={header_secret:?}; authorization={auth:?}; basic-decoded={decoded:?}"
|
||||
)
|
||||
}
|
||||
|
||||
fn extract_qa_reply(payload: &MtaHookPayload, expected_domain: &str) -> Option<(i64, String)> {
|
||||
if !payload.messages.is_empty() {
|
||||
for message in &payload.messages {
|
||||
if let Some(reply) = extract_qa_reply_from_message(
|
||||
&message.envelope.to,
|
||||
expected_domain,
|
||||
message.message.subject.as_deref().or(message.message.headers.subject.as_deref()),
|
||||
if message.message.contents.is_empty() {
|
||||
&message.contents
|
||||
} else {
|
||||
&message.message.contents
|
||||
},
|
||||
) {
|
||||
return Some(reply);
|
||||
}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
extract_qa_reply_from_message(
|
||||
&payload.envelope.to,
|
||||
expected_domain,
|
||||
payload
|
||||
.message
|
||||
.subject
|
||||
.as_deref()
|
||||
.or(payload.message.headers.subject.as_deref()),
|
||||
&payload.message.contents,
|
||||
)
|
||||
}
|
||||
|
||||
fn value_at_path<'a>(value: &'a Value, path: &[&str]) -> Option<&'a Value> {
|
||||
let mut current = value;
|
||||
for key in path {
|
||||
current = current.get(*key)?;
|
||||
}
|
||||
Some(current)
|
||||
}
|
||||
|
||||
fn string_from_value(value: &Value) -> Option<String> {
|
||||
match value {
|
||||
Value::String(s) => Some(s.clone()),
|
||||
Value::Object(map) => map
|
||||
.get("address")
|
||||
.or_else(|| map.get("email"))
|
||||
.or_else(|| map.get("value"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(ToOwned::to_owned),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn recipients_from_value(value: Option<&Value>) -> Vec<Recipient> {
|
||||
let Some(value) = value else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
match value {
|
||||
Value::Array(values) => values
|
||||
.iter()
|
||||
.filter_map(|v| string_from_value(v).map(Recipient::Address))
|
||||
.collect(),
|
||||
_ => string_from_value(value)
|
||||
.map(Recipient::Address)
|
||||
.into_iter()
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn string_at_paths(value: &Value, paths: &[&[&str]]) -> Option<String> {
|
||||
paths.iter().find_map(|path| {
|
||||
value_at_path(value, path)
|
||||
.and_then(|v| v.as_str())
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
}
|
||||
|
||||
fn subject_from_headers_value(value: Option<&Value>) -> Option<String> {
|
||||
let headers = value?.as_array()?;
|
||||
headers.iter().find_map(|header| {
|
||||
let parts = header.as_array()?;
|
||||
let name = parts.first()?.as_str()?.trim();
|
||||
if !name.eq_ignore_ascii_case("Subject") {
|
||||
return None;
|
||||
}
|
||||
parts
|
||||
.get(1)?
|
||||
.as_str()
|
||||
.map(|s| s.trim().to_string())
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_qa_reply_from_value(payload: &Value, expected_domain: &str) -> Option<(i64, String)> {
|
||||
if let Some(messages) = payload.get("messages").and_then(Value::as_array) {
|
||||
for message in messages {
|
||||
if let Some(reply) = extract_qa_reply_from_message(
|
||||
&recipients_from_value(value_at_path(message, &["envelope", "to"])),
|
||||
expected_domain,
|
||||
string_at_paths(
|
||||
message,
|
||||
&[
|
||||
&["message", "subject"],
|
||||
&["message", "headers", "subject"],
|
||||
&["headers", "subject"],
|
||||
],
|
||||
)
|
||||
.or_else(|| subject_from_headers_value(value_at_path(message, &["message", "headers"])))
|
||||
.as_deref(),
|
||||
&string_at_paths(message, &[&["message", "contents"], &["contents"], &["raw_message"]])
|
||||
.unwrap_or_default(),
|
||||
) {
|
||||
return Some(reply);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extract_qa_reply_from_message(
|
||||
&recipients_from_value(value_at_path(payload, &["envelope", "to"])),
|
||||
expected_domain,
|
||||
string_at_paths(
|
||||
payload,
|
||||
&[
|
||||
&["message", "subject"],
|
||||
&["message", "headers", "subject"],
|
||||
&["headers", "subject"],
|
||||
],
|
||||
)
|
||||
.or_else(|| subject_from_headers_value(value_at_path(payload, &["message", "headers"])))
|
||||
.as_deref(),
|
||||
&string_at_paths(payload, &[&["message", "contents"], &["contents"], &["raw_message"]])
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
}
|
||||
|
||||
fn extract_qa_reply_from_message(
|
||||
recipients: &[Recipient],
|
||||
expected_domain: &str,
|
||||
subject: Option<&str>,
|
||||
contents: &str,
|
||||
) -> Option<(i64, String)> {
|
||||
let _qa_recipient = recipients.iter().find(|recipient| {
|
||||
let address = recipient.address();
|
||||
let Some((local, domain)) = address.rsplit_once('@') else {
|
||||
return false;
|
||||
};
|
||||
|
||||
local.eq_ignore_ascii_case("qa") && domain.eq_ignore_ascii_case(expected_domain)
|
||||
})?;
|
||||
|
||||
let subject = subject.map(ToOwned::to_owned).or_else(|| {
|
||||
contents
|
||||
.replace("\r\n", "\n")
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("Subject: ").map(ToOwned::to_owned))
|
||||
})?;
|
||||
let id = email::extract_id_from_subject(&subject).ok()?;
|
||||
let body = email::extract_plain_text_body(contents);
|
||||
if body.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some((id, body))
|
||||
}
|
||||
|
||||
pub async fn webhook(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
body: String,
|
||||
) -> Result<Json<MtaHookResponse>, (StatusCode, String)> {
|
||||
if !webhook_secret_matches(&headers, &state.webhook_secret) {
|
||||
eprintln!(
|
||||
"Rejected webhook: invalid secret; expected_len={}; {}",
|
||||
state.webhook_secret.len(),
|
||||
webhook_secret_debug(&headers)
|
||||
);
|
||||
return Err((StatusCode::UNAUTHORIZED, "invalid secret".to_string()));
|
||||
}
|
||||
|
||||
let payload_value: Value = match serde_json::from_str(&body) {
|
||||
Ok(payload) => payload,
|
||||
Err(err) => {
|
||||
eprintln!("Rejected webhook: invalid JSON payload: {err}; body={body}");
|
||||
return Ok(Json(MtaHookResponse { action: "accept" }));
|
||||
}
|
||||
};
|
||||
|
||||
let parsed_reply = serde_json::from_value::<MtaHookPayload>(payload_value.clone())
|
||||
.ok()
|
||||
.and_then(|payload| extract_qa_reply(&payload, &state.qa_reply_domain))
|
||||
.or_else(|| extract_qa_reply_from_value(&payload_value, &state.qa_reply_domain));
|
||||
|
||||
if let Some((id, body)) = parsed_reply {
|
||||
let db = state
|
||||
.db
|
||||
.lock()
|
||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "db error".to_string()))?;
|
||||
db.execute(
|
||||
"UPDATE questions SET answer = ?1, answered_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') \
|
||||
WHERE id = ?2 AND answer IS NULL",
|
||||
rusqlite::params![body, id],
|
||||
)
|
||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "update error".to_string()))?;
|
||||
|
||||
eprintln!("Stored Q&A reply for question #{id}");
|
||||
return Ok(Json(MtaHookResponse { action: "discard" }));
|
||||
}
|
||||
|
||||
eprintln!("Q&A webhook accepted payload without matched reply: {payload_value}");
|
||||
Ok(Json(MtaHookResponse { action: "accept" }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use axum::http::HeaderMap;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{extract_qa_reply, extract_qa_reply_from_value, webhook_secret_matches, MtaHookPayload};
|
||||
|
||||
#[test]
|
||||
fn extracts_reply_from_current_stalwart_payload() {
|
||||
let payload: MtaHookPayload = serde_json::from_str(
|
||||
r#"{
|
||||
"envelope": {
|
||||
"to": [
|
||||
{
|
||||
"address": "qa@extremist.software"
|
||||
}
|
||||
]
|
||||
},
|
||||
"message": {
|
||||
"subject": "Re: 42 - hello",
|
||||
"contents": "This is the answer.\n\nOn earlier mail wrote:\n> quoted"
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
extract_qa_reply(&payload, "extremist.software"),
|
||||
Some((42, "This is the answer.".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_reply_from_legacy_batch_payload() {
|
||||
let payload: MtaHookPayload = serde_json::from_str(
|
||||
r#"{
|
||||
"messages": [
|
||||
{
|
||||
"envelope": {
|
||||
"to": ["qa@extremist.software"]
|
||||
},
|
||||
"message": {
|
||||
"subject": "Re: 7 - legacy"
|
||||
},
|
||||
"contents": "Legacy answer"
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
extract_qa_reply(&payload, "extremist.software"),
|
||||
Some((7, "Legacy answer".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_header_secret() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("X-Webhook-Secret", "topsecret".parse().unwrap());
|
||||
|
||||
assert!(webhook_secret_matches(&headers, "topsecret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_basic_auth_password() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
axum::http::header::AUTHORIZATION,
|
||||
"Basic dXNlcjp0b3BzZWNyZXQ=".parse().unwrap(),
|
||||
);
|
||||
|
||||
assert!(webhook_secret_matches(&headers, "topsecret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_reply_from_value_with_string_recipient() {
|
||||
let payload: Value = serde_json::from_str(
|
||||
r#"{
|
||||
"envelope": {
|
||||
"to": "qa@extremist.software"
|
||||
},
|
||||
"message": {
|
||||
"headers": {
|
||||
"subject": "Re: 9 - hi"
|
||||
},
|
||||
"contents": "Answer body"
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
extract_qa_reply_from_value(&payload, "extremist.software"),
|
||||
Some((9, "Answer body".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_reply_from_value_with_header_pairs() {
|
||||
let payload: Value = serde_json::from_str(
|
||||
r#"{
|
||||
"envelope": {
|
||||
"to": [{"address":"qa@extremist.software"}]
|
||||
},
|
||||
"message": {
|
||||
"headers": [
|
||||
["From", " jet@extremist.software\r\n"],
|
||||
["Subject", " Re: 11 - hi\r\n"]
|
||||
],
|
||||
"contents": "Answer from header pairs"
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
extract_qa_reply_from_value(&payload, "extremist.software"),
|
||||
Some((11, "Answer from header pairs".to_string()))
|
||||
);
|
||||
}
|
||||
}
|
||||
9
api/src/main.rs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
mod email;
|
||||
mod handlers;
|
||||
mod rate_limit;
|
||||
mod serve;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
serve::run().await.expect("server error");
|
||||
}
|
||||
35
api/src/rate_limit.rs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Instant;
|
||||
|
||||
pub struct RateLimiter {
|
||||
max_requests: u32,
|
||||
window_secs: u64,
|
||||
clients: Mutex<HashMap<String, (u32, Instant)>>,
|
||||
}
|
||||
|
||||
impl RateLimiter {
|
||||
pub fn new(max_requests: u32, window_secs: u64) -> Self {
|
||||
Self {
|
||||
max_requests,
|
||||
window_secs,
|
||||
clients: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn check(&self, ip: &str) -> bool {
|
||||
let mut clients = self.clients.lock().unwrap();
|
||||
let now = Instant::now();
|
||||
|
||||
let entry = clients.entry(ip.to_string()).or_insert((0, now));
|
||||
if now.duration_since(entry.1).as_secs() >= self.window_secs {
|
||||
*entry = (1, now);
|
||||
return true;
|
||||
}
|
||||
if entry.0 >= self.max_requests {
|
||||
return false;
|
||||
}
|
||||
entry.0 += 1;
|
||||
true
|
||||
}
|
||||
}
|
||||
69
api/src/serve.rs
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use axum::routing::{get, post};
|
||||
use axum::Router;
|
||||
use rusqlite::Connection;
|
||||
use tower_http::cors::CorsLayer;
|
||||
|
||||
use crate::handlers;
|
||||
use crate::rate_limit::RateLimiter;
|
||||
|
||||
pub struct AppState {
|
||||
pub db: Mutex<Connection>,
|
||||
pub notify_email: String,
|
||||
pub mail_domain: String,
|
||||
pub qa_reply_domain: String,
|
||||
pub rate_limiter: RateLimiter,
|
||||
pub webhook_secret: String,
|
||||
}
|
||||
|
||||
pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let db_path = std::env::var("QA_DB_PATH").unwrap_or_else(|_| "qa.db".to_string());
|
||||
let notify_email = std::env::var("QA_NOTIFY_EMAIL").expect("QA_NOTIFY_EMAIL must be set");
|
||||
let mail_domain =
|
||||
std::env::var("QA_MAIL_DOMAIN").unwrap_or_else(|_| "extremist.software".to_string());
|
||||
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;")?;
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS questions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
question TEXT NOT NULL,
|
||||
answer TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
answered_at TEXT
|
||||
);",
|
||||
)?;
|
||||
|
||||
let state = Arc::new(AppState {
|
||||
db: Mutex::new(conn),
|
||||
notify_email,
|
||||
mail_domain,
|
||||
qa_reply_domain,
|
||||
rate_limiter: RateLimiter::new(5, 3600),
|
||||
webhook_secret,
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/api/questions",
|
||||
get(handlers::get_questions).post(handlers::post_question),
|
||||
)
|
||||
.route("/api/questions/stats", get(handlers::get_question_stats))
|
||||
.route("/qa/rss.xml", get(handlers::get_question_rss))
|
||||
.route("/api/webhook", post(handlers::webhook))
|
||||
.layer(CorsLayer::permissive())
|
||||
.with_state(state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&listen_target).await?;
|
||||
println!("Listening on {listen_target}");
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
[build]
|
||||
target = "wasm32-unknown-unknown"
|
||||
7
cgol/.gitignore
vendored
|
|
@ -1,7 +0,0 @@
|
|||
/target
|
||||
**/*.rs.bk
|
||||
Cargo.lock
|
||||
bin/
|
||||
pkg/
|
||||
wasm-pack.log
|
||||
/pkg
|
||||
3
cgol/.vscode/settings.json
vendored
|
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
"rust-analyzer.cargo.target": "wasm32-unknown-unknown",
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
[package]
|
||||
name = "cgol"
|
||||
version = "0.1.0"
|
||||
authors = ["jet"]
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[features]
|
||||
default = ["console_error_panic_hook"]
|
||||
|
||||
[dependencies]
|
||||
wasm-bindgen = "0.2"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = [
|
||||
"console",
|
||||
"CanvasRenderingContext2d",
|
||||
"Document",
|
||||
"HtmlCanvasElement",
|
||||
"Window",
|
||||
"MouseEvent",
|
||||
"Element",
|
||||
"EventTarget",
|
||||
"Performance",
|
||||
"DomRect",
|
||||
"TouchEvent",
|
||||
"Touch",
|
||||
"TouchList",
|
||||
"ImageData",
|
||||
] }
|
||||
|
||||
# The `console_error_panic_hook` crate provides better debugging of panics by
|
||||
# logging them with `console.error`. This is great for development, but requires
|
||||
# all the `std::fmt` and `std::panicking` infrastructure, so isn't great for
|
||||
# code size when deploying.
|
||||
console_error_panic_hook = { version = "0.1", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
wasm-bindgen-test = "0.3"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
<div align="center">
|
||||
|
||||
<h1><code>wasm-pack-template</code></h1>
|
||||
|
||||
<strong>A template for kick starting a Rust and WebAssembly project using <a href="https://github.com/rustwasm/wasm-pack">wasm-pack</a>.</strong>
|
||||
|
||||
<p>
|
||||
<a href="https://travis-ci.org/rustwasm/wasm-pack-template"><img src="https://img.shields.io/travis/rustwasm/wasm-pack-template.svg?style=flat-square" alt="Build Status" /></a>
|
||||
</p>
|
||||
|
||||
<h3>
|
||||
<a href="https://rustwasm.github.io/docs/wasm-pack/tutorials/npm-browser-packages/index.html">Tutorial</a>
|
||||
<span> | </span>
|
||||
<a href="https://discordapp.com/channels/442252698964721669/443151097398296587">Chat</a>
|
||||
</h3>
|
||||
|
||||
<sub>Built with 🦀🕸 by <a href="https://rustwasm.github.io/">The Rust and WebAssembly Working Group</a></sub>
|
||||
</div>
|
||||
|
||||
## About
|
||||
|
||||
[**📚 Read this template tutorial! 📚**][template-docs]
|
||||
|
||||
This template is designed for compiling Rust libraries into WebAssembly and
|
||||
publishing the resulting package to NPM.
|
||||
|
||||
Be sure to check out [other `wasm-pack` tutorials online][tutorials] for other
|
||||
templates and usages of `wasm-pack`.
|
||||
|
||||
[tutorials]: https://rustwasm.github.io/docs/wasm-pack/tutorials/index.html
|
||||
[template-docs]: https://rustwasm.github.io/docs/wasm-pack/tutorials/npm-browser-packages/index.html
|
||||
|
||||
## 🚴 Usage
|
||||
|
||||
### 🐑 Use `cargo generate` to Clone this Template
|
||||
|
||||
[Learn more about `cargo generate` here.](https://github.com/ashleygwilliams/cargo-generate)
|
||||
|
||||
```
|
||||
cargo generate --git https://github.com/rustwasm/wasm-pack-template.git --name my-project
|
||||
cd my-project
|
||||
```
|
||||
|
||||
### 🛠️ Build with `wasm-pack build`
|
||||
|
||||
```
|
||||
wasm-pack build
|
||||
```
|
||||
|
||||
### 🔬 Test in Headless Browsers with `wasm-pack test`
|
||||
|
||||
```
|
||||
wasm-pack test --headless --firefox
|
||||
```
|
||||
|
||||
### 🎁 Publish to NPM with `wasm-pack publish`
|
||||
|
||||
```
|
||||
wasm-pack publish
|
||||
```
|
||||
|
||||
## 🔋 Batteries Included
|
||||
|
||||
* [`wasm-bindgen`](https://github.com/rustwasm/wasm-bindgen) for communicating
|
||||
between WebAssembly and JavaScript.
|
||||
* [`console_error_panic_hook`](https://github.com/rustwasm/console_error_panic_hook)
|
||||
for logging panic messages to the developer console.
|
||||
* `LICENSE-APACHE` and `LICENSE-MIT`: most Rust projects are licensed this way, so these are included for you
|
||||
|
||||
## License
|
||||
|
||||
Licensed under either of
|
||||
|
||||
* Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0)
|
||||
* MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT)
|
||||
|
||||
at your option.
|
||||
|
||||
### Contribution
|
||||
|
||||
Unless you explicitly state otherwise, any contribution intentionally
|
||||
submitted for inclusion in the work by you, as defined in the Apache-2.0
|
||||
license, shall be dual licensed as above, without any additional terms or
|
||||
conditions.
|
||||
463
cgol/src/lib.rs
|
|
@ -1,463 +0,0 @@
|
|||
mod utils;
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen::Clamped;
|
||||
use wasm_bindgen::JsCast;
|
||||
|
||||
use js_sys::Math;
|
||||
use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement, ImageData, Window};
|
||||
|
||||
const CELL_SIZE: u32 = 20;
|
||||
const SIMULATION_FPS: f64 = 60.0;
|
||||
const SIMULATION_FRAME_MS: f64 = 1000.0 / SIMULATION_FPS;
|
||||
const HUE_ROTATION_PERIOD_MS: f64 = 3000.0;
|
||||
|
||||
fn random_hue() -> u8 {
|
||||
(Math::random() * 256.0) as u8
|
||||
}
|
||||
|
||||
fn mix_colors(hues: &[u8]) -> u8 {
|
||||
if hues.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let (sum_sin, sum_cos) = hues
|
||||
.iter()
|
||||
.map(|&hue| (hue as f64 * 360.0 / 255.0).to_radians())
|
||||
.fold((0.0, 0.0), |acc, h_rad| {
|
||||
(acc.0 + h_rad.sin(), acc.1 + h_rad.cos())
|
||||
});
|
||||
let avg_hue_degrees = sum_sin.atan2(sum_cos).to_degrees().rem_euclid(360.0);
|
||||
(avg_hue_degrees * 255.0 / 360.0) as u8
|
||||
}
|
||||
|
||||
/// Convert HSL hue (0-255) to RGB
|
||||
/// Using full saturation (100%) and lightness (50%)
|
||||
fn hue_to_rgb(hue: u8) -> (u8, u8, u8) {
|
||||
let h = hue as f64 / 255.0 * 6.0;
|
||||
let x = 1.0 - (h % 2.0 - 1.0).abs();
|
||||
|
||||
let (r, g, b) = match h as u32 {
|
||||
0 => (1.0, x, 0.0),
|
||||
1 => (x, 1.0, 0.0),
|
||||
2 => (0.0, 1.0, x),
|
||||
3 => (0.0, x, 1.0),
|
||||
4 => (x, 0.0, 1.0),
|
||||
_ => (1.0, 0.0, x),
|
||||
};
|
||||
|
||||
((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Cell {
|
||||
Dead,
|
||||
Alive { hue: u8 },
|
||||
}
|
||||
|
||||
struct Universe {
|
||||
width: u32,
|
||||
height: u32,
|
||||
cells: Vec<Cell>,
|
||||
next_cells: Vec<Cell>,
|
||||
neighbor_hues_buffer: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Universe {
|
||||
fn new(width: u32, height: u32) -> Self {
|
||||
let size = (width * height) as usize;
|
||||
let mut u = Self {
|
||||
width,
|
||||
height,
|
||||
cells: vec![Cell::Dead; size],
|
||||
next_cells: vec![Cell::Dead; size],
|
||||
neighbor_hues_buffer: Vec::with_capacity(8),
|
||||
};
|
||||
u.randomize();
|
||||
u
|
||||
}
|
||||
|
||||
fn randomize(&mut self) {
|
||||
for c in &mut self.cells {
|
||||
*c = if Math::random() < 0.5 {
|
||||
Cell::Alive { hue: random_hue() }
|
||||
} else {
|
||||
Cell::Dead
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn index(&self, row: u32, col: u32) -> usize {
|
||||
(row * self.width + col) as usize
|
||||
}
|
||||
|
||||
fn count_neighbors_and_get_hues(&mut self, row: u32, col: u32) -> u8 {
|
||||
self.neighbor_hues_buffer.clear();
|
||||
|
||||
let north = if row == 0 { self.height - 1 } else { row - 1 };
|
||||
let south = if row == self.height - 1 { 0 } else { row + 1 };
|
||||
let west = if col == 0 { self.width - 1 } else { col - 1 };
|
||||
let east = if col == self.width - 1 { 0 } else { col + 1 };
|
||||
|
||||
let neighbors = [
|
||||
(north, west),
|
||||
(north, col),
|
||||
(north, east),
|
||||
(row, west),
|
||||
(row, east),
|
||||
(south, west),
|
||||
(south, col),
|
||||
(south, east),
|
||||
];
|
||||
|
||||
for (nr, nc) in neighbors {
|
||||
let idx = self.index(nr, nc);
|
||||
if let Cell::Alive { hue } = self.cells[idx] {
|
||||
self.neighbor_hues_buffer.push(hue);
|
||||
}
|
||||
}
|
||||
|
||||
self.neighbor_hues_buffer.len() as u8
|
||||
}
|
||||
|
||||
fn tick(&mut self) {
|
||||
for row in 0..self.height {
|
||||
for col in 0..self.width {
|
||||
let idx = self.index(row, col);
|
||||
let cell = self.cells[idx];
|
||||
let neighbor_count = self.count_neighbors_and_get_hues(row, col);
|
||||
|
||||
self.next_cells[idx] = match (cell, neighbor_count) {
|
||||
(Cell::Alive { .. }, x) if x < 2 => Cell::Dead,
|
||||
(Cell::Alive { hue }, 2) | (Cell::Alive { hue }, 3) => Cell::Alive { hue },
|
||||
(Cell::Alive { .. }, x) if x > 3 => Cell::Dead,
|
||||
(Cell::Dead, 3) => {
|
||||
let mixed_hue = mix_colors(&self.neighbor_hues_buffer);
|
||||
Cell::Alive { hue: mixed_hue }
|
||||
}
|
||||
(otherwise, _) => otherwise,
|
||||
};
|
||||
}
|
||||
}
|
||||
std::mem::swap(&mut self.cells, &mut self.next_cells);
|
||||
}
|
||||
|
||||
fn set_alive_block(&mut self, center_row: i32, center_col: i32, half: i32, hue: u8) {
|
||||
let h = self.height as i32;
|
||||
let w = self.width as i32;
|
||||
for dr in -half..=half {
|
||||
for dc in -half..=half {
|
||||
let r = (center_row + dr).rem_euclid(h) as u32;
|
||||
let c = (center_col + dc).rem_euclid(w) as u32;
|
||||
let idx = self.index(r, c);
|
||||
self.cells[idx] = Cell::Alive { hue };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AppState {
|
||||
window: Window,
|
||||
canvas: HtmlCanvasElement,
|
||||
ctx: CanvasRenderingContext2d,
|
||||
universe: Universe,
|
||||
cursor_row: i32,
|
||||
cursor_col: i32,
|
||||
last_frame_time: f64,
|
||||
cursor_active: bool,
|
||||
// Reusable pixel buffer for ImageData (avoids allocation each frame)
|
||||
pixel_buffer: Vec<u8>,
|
||||
canvas_width: u32,
|
||||
canvas_height: u32,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
fn new() -> Result<Self, JsValue> {
|
||||
utils::set_panic_hook();
|
||||
|
||||
let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?;
|
||||
let document = window
|
||||
.document()
|
||||
.ok_or_else(|| JsValue::from_str("no document"))?;
|
||||
|
||||
let canvas = document
|
||||
.get_element_by_id("canvas")
|
||||
.and_then(|e| e.dyn_into::<HtmlCanvasElement>().ok())
|
||||
.ok_or_else(|| JsValue::from_str("canvas element not found"))?;
|
||||
|
||||
let ctx = canvas
|
||||
.get_context("2d")?
|
||||
.ok_or_else(|| JsValue::from_str("no 2d context"))?
|
||||
.dyn_into::<CanvasRenderingContext2d>()?;
|
||||
|
||||
// Disable image smoothing for sharp pixel scaling
|
||||
ctx.set_image_smoothing_enabled(false);
|
||||
|
||||
let mut state = Self {
|
||||
window,
|
||||
canvas,
|
||||
ctx,
|
||||
universe: Universe::new(1, 1),
|
||||
cursor_row: 0,
|
||||
cursor_col: 0,
|
||||
last_frame_time: 0.0,
|
||||
cursor_active: false,
|
||||
pixel_buffer: Vec::new(),
|
||||
canvas_width: 0,
|
||||
canvas_height: 0,
|
||||
};
|
||||
|
||||
state.resize_canvas_and_universe();
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
fn get_current_time(&self) -> f64 {
|
||||
self.window.performance().unwrap().now()
|
||||
}
|
||||
|
||||
fn resize_canvas_and_universe(&mut self) {
|
||||
let width = self.window.inner_width().unwrap().as_f64().unwrap();
|
||||
let height = self.window.inner_height().unwrap().as_f64().unwrap();
|
||||
|
||||
let dpr = self.window.device_pixel_ratio();
|
||||
let css_w = width;
|
||||
let css_h = height;
|
||||
let element = self.canvas.dyn_ref::<web_sys::Element>().unwrap();
|
||||
element
|
||||
.set_attribute(
|
||||
"style",
|
||||
&format!(
|
||||
"position:fixed;inset:0;width:{}px;height:{}px;image-rendering:pixelated",
|
||||
css_w, css_h
|
||||
),
|
||||
)
|
||||
.ok();
|
||||
|
||||
self.canvas_width = (css_w * dpr) as u32;
|
||||
self.canvas_height = (css_h * dpr) as u32;
|
||||
self.canvas.set_width(self.canvas_width);
|
||||
self.canvas.set_height(self.canvas_height);
|
||||
|
||||
// Disable image smoothing after resize
|
||||
self.ctx.set_image_smoothing_enabled(false);
|
||||
|
||||
// Clear canvas
|
||||
self.ctx.set_fill_style_str("black");
|
||||
self.ctx.fill_rect(0.0, 0.0, self.canvas_width as f64, self.canvas_height as f64);
|
||||
|
||||
let cols = (self.canvas_width / CELL_SIZE).max(1);
|
||||
let rows = (self.canvas_height / CELL_SIZE).max(1);
|
||||
self.universe = Universe::new(cols, rows);
|
||||
|
||||
// Allocate pixel buffer for the universe size (1 pixel per cell)
|
||||
// We'll draw at universe resolution and let CSS scale it up
|
||||
let buffer_size = (cols * rows * 4) as usize;
|
||||
self.pixel_buffer = vec![0u8; buffer_size];
|
||||
}
|
||||
|
||||
fn draw_scaled(&mut self) {
|
||||
let grid_width = self.universe.width;
|
||||
let grid_height = self.universe.height;
|
||||
let cell_w = CELL_SIZE;
|
||||
let cell_h = CELL_SIZE;
|
||||
|
||||
// Fill pixel buffer at full canvas resolution
|
||||
let canvas_w = self.canvas_width as usize;
|
||||
let canvas_h = self.canvas_height as usize;
|
||||
|
||||
// Resize buffer if needed
|
||||
let needed_size = canvas_w * canvas_h * 4;
|
||||
if self.pixel_buffer.len() != needed_size {
|
||||
self.pixel_buffer.resize(needed_size, 0);
|
||||
}
|
||||
|
||||
// Fill with black first (for dead cells)
|
||||
for chunk in self.pixel_buffer.chunks_exact_mut(4) {
|
||||
chunk[0] = 0;
|
||||
chunk[1] = 0;
|
||||
chunk[2] = 0;
|
||||
chunk[3] = 255;
|
||||
}
|
||||
|
||||
// Draw each cell as a CELL_SIZE × CELL_SIZE block
|
||||
for row in 0..grid_height {
|
||||
for col in 0..grid_width {
|
||||
let cell_idx = self.universe.index(row, col);
|
||||
|
||||
if let Cell::Alive { hue } = self.universe.cells[cell_idx] {
|
||||
let (r, g, b) = hue_to_rgb(hue);
|
||||
|
||||
let start_x = (col * cell_w) as usize;
|
||||
let start_y = (row * cell_h) as usize;
|
||||
|
||||
for py in 0..cell_h as usize {
|
||||
let y = start_y + py;
|
||||
if y >= canvas_h {
|
||||
break;
|
||||
}
|
||||
|
||||
for px in 0..cell_w as usize {
|
||||
let x = start_x + px;
|
||||
if x >= canvas_w {
|
||||
break;
|
||||
}
|
||||
|
||||
let pixel_idx = (y * canvas_w + x) * 4;
|
||||
self.pixel_buffer[pixel_idx] = r;
|
||||
self.pixel_buffer[pixel_idx + 1] = g;
|
||||
self.pixel_buffer[pixel_idx + 2] = b;
|
||||
self.pixel_buffer[pixel_idx + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Single putImageData call with full canvas
|
||||
if let Ok(image_data) = ImageData::new_with_u8_clamped_array_and_sh(
|
||||
Clamped(&self.pixel_buffer),
|
||||
self.canvas_width,
|
||||
self.canvas_height,
|
||||
) {
|
||||
self.ctx.put_image_data(&image_data, 0.0, 0.0).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn start() -> Result<(), JsValue> {
|
||||
let state = AppState::new()?;
|
||||
let state_rc = Rc::new(RefCell::new(state));
|
||||
|
||||
let canvas = state_rc.borrow().canvas.clone();
|
||||
let window = state_rc.borrow().window.clone();
|
||||
let document = window.document().unwrap();
|
||||
|
||||
// Mouse move handler
|
||||
let state_for_mouse = state_rc.clone();
|
||||
let canvas_for_mouse = canvas.clone();
|
||||
let window_for_mouse = window.clone();
|
||||
let mouse_closure = Closure::wrap(Box::new(move |event: web_sys::MouseEvent| {
|
||||
let rect = canvas_for_mouse.get_bounding_client_rect();
|
||||
let dpr = window_for_mouse.device_pixel_ratio();
|
||||
let x = (event.client_x() as f64 - rect.left()) * dpr;
|
||||
let y = (event.client_y() as f64 - rect.top()) * dpr;
|
||||
let mut s = state_for_mouse.borrow_mut();
|
||||
s.cursor_col = (x / CELL_SIZE as f64) as i32;
|
||||
s.cursor_row = (y / CELL_SIZE as f64) as i32;
|
||||
s.cursor_active = true;
|
||||
}) as Box<dyn FnMut(_)>);
|
||||
|
||||
document.add_event_listener_with_callback("mousemove", mouse_closure.as_ref().unchecked_ref())?;
|
||||
mouse_closure.forget();
|
||||
|
||||
// Touch move handler
|
||||
let state_for_touch_move = state_rc.clone();
|
||||
let canvas_for_touch_move = canvas.clone();
|
||||
let window_for_touch_move = window.clone();
|
||||
let touch_move_closure = Closure::wrap(Box::new(move |event: web_sys::TouchEvent| {
|
||||
event.prevent_default();
|
||||
if let Some(touch) = event.touches().get(0) {
|
||||
let rect = canvas_for_touch_move.get_bounding_client_rect();
|
||||
let dpr = window_for_touch_move.device_pixel_ratio();
|
||||
let x = (touch.client_x() as f64 - rect.left()) * dpr;
|
||||
let y = (touch.client_y() as f64 - rect.top()) * dpr;
|
||||
let mut s = state_for_touch_move.borrow_mut();
|
||||
s.cursor_col = (x / CELL_SIZE as f64) as i32;
|
||||
s.cursor_row = (y / CELL_SIZE as f64) as i32;
|
||||
s.cursor_active = true;
|
||||
}
|
||||
}) as Box<dyn FnMut(_)>);
|
||||
|
||||
document
|
||||
.add_event_listener_with_callback("touchmove", touch_move_closure.as_ref().unchecked_ref())?;
|
||||
touch_move_closure.forget();
|
||||
|
||||
// Touch start handler
|
||||
let state_for_touch_start = state_rc.clone();
|
||||
let canvas_for_touch_start = canvas.clone();
|
||||
let window_for_touch_start = window.clone();
|
||||
let touch_start_closure = Closure::wrap(Box::new(move |event: web_sys::TouchEvent| {
|
||||
event.prevent_default();
|
||||
if let Some(touch) = event.touches().get(0) {
|
||||
let rect = canvas_for_touch_start.get_bounding_client_rect();
|
||||
let dpr = window_for_touch_start.device_pixel_ratio();
|
||||
let x = (touch.client_x() as f64 - rect.left()) * dpr;
|
||||
let y = (touch.client_y() as f64 - rect.top()) * dpr;
|
||||
let mut s = state_for_touch_start.borrow_mut();
|
||||
s.cursor_col = (x / CELL_SIZE as f64) as i32;
|
||||
s.cursor_row = (y / CELL_SIZE as f64) as i32;
|
||||
s.cursor_active = true;
|
||||
}
|
||||
}) as Box<dyn FnMut(_)>);
|
||||
|
||||
document
|
||||
.add_event_listener_with_callback("touchstart", touch_start_closure.as_ref().unchecked_ref())?;
|
||||
touch_start_closure.forget();
|
||||
|
||||
// Touch end handler
|
||||
let state_for_touch_end = state_rc.clone();
|
||||
let touch_end_closure = Closure::wrap(Box::new(move |_event: web_sys::TouchEvent| {
|
||||
let mut s = state_for_touch_end.borrow_mut();
|
||||
s.cursor_active = false;
|
||||
}) as Box<dyn FnMut(_)>);
|
||||
|
||||
document
|
||||
.add_event_listener_with_callback("touchend", touch_end_closure.as_ref().unchecked_ref())?;
|
||||
touch_end_closure.forget();
|
||||
|
||||
// Resize handler
|
||||
let state_for_resize = state_rc.clone();
|
||||
let resize_closure = Closure::wrap(Box::new(move || {
|
||||
let mut s = state_for_resize.borrow_mut();
|
||||
s.resize_canvas_and_universe();
|
||||
}) as Box<dyn FnMut()>);
|
||||
|
||||
window.add_event_listener_with_callback("resize", resize_closure.as_ref().unchecked_ref())?;
|
||||
resize_closure.forget();
|
||||
|
||||
// Animation loop
|
||||
let f = Rc::new(RefCell::new(None::<Closure<dyn FnMut()>>));
|
||||
let g = f.clone();
|
||||
let state_for_anim = state_rc.clone();
|
||||
let window_for_anim = window.clone();
|
||||
|
||||
*g.borrow_mut() = Some(Closure::wrap(Box::new(move || {
|
||||
let current_time = state_for_anim.borrow().get_current_time();
|
||||
|
||||
{
|
||||
let mut s = state_for_anim.borrow_mut();
|
||||
|
||||
// Run simulation FIRST (throttled to 60 FPS max)
|
||||
// This way cursor-placed cells won't be immediately killed
|
||||
if current_time - s.last_frame_time >= SIMULATION_FRAME_MS {
|
||||
s.last_frame_time = current_time;
|
||||
s.universe.tick();
|
||||
}
|
||||
|
||||
// Process cursor input AFTER tick for responsiveness
|
||||
// Cells placed here survive until the next tick
|
||||
if s.cursor_active {
|
||||
let cursor_row = s.cursor_row;
|
||||
let cursor_col = s.cursor_col;
|
||||
|
||||
let hue = ((current_time % HUE_ROTATION_PERIOD_MS) / HUE_ROTATION_PERIOD_MS * 256.0) as u8;
|
||||
s.universe.set_alive_block(cursor_row, cursor_col, 2, hue);
|
||||
}
|
||||
|
||||
// Draw every frame
|
||||
s.draw_scaled();
|
||||
}
|
||||
|
||||
window_for_anim
|
||||
.request_animation_frame(f.borrow().as_ref().unwrap().as_ref().unchecked_ref())
|
||||
.ok();
|
||||
}) as Box<dyn FnMut()>));
|
||||
|
||||
window.request_animation_frame(g.borrow().as_ref().unwrap().as_ref().unchecked_ref())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
pub fn set_panic_hook() {
|
||||
// When the `console_error_panic_hook` feature is enabled, we can call the
|
||||
// `set_panic_hook` function at least once during initialization, and then
|
||||
// we will get better error messages if our code ever panics.
|
||||
//
|
||||
// For more details see
|
||||
// https://github.com/rustwasm/console_error_panic_hook#readme
|
||||
#[cfg(feature = "console_error_panic_hook")]
|
||||
console_error_panic_hook::set_once();
|
||||
}
|
||||
|
|
@ -1,45 +1,42 @@
|
|||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: import.meta.dirname,
|
||||
});
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ['.next']
|
||||
},
|
||||
...compat.extends("next/core-web-vitals"),
|
||||
{
|
||||
files: ['**/*.ts', '**/*.tsx'],
|
||||
extends: [
|
||||
...tseslint.configs.recommended,
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
...tseslint.configs.stylisticTypeChecked
|
||||
],
|
||||
rules: {
|
||||
"@typescript-eslint/array-type": "off",
|
||||
"@typescript-eslint/consistent-type-definitions": "off",
|
||||
"@typescript-eslint/consistent-type-imports": [
|
||||
"warn",
|
||||
{ prefer: "type-imports", fixStyle: "inline-type-imports" },
|
||||
],
|
||||
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
|
||||
"@typescript-eslint/require-await": "off",
|
||||
"@typescript-eslint/no-misused-promises": [
|
||||
"error",
|
||||
{ checksVoidReturn: { attributes: false } },
|
||||
],
|
||||
},
|
||||
ignores: ["dist"],
|
||||
},
|
||||
{
|
||||
linterOptions: {
|
||||
reportUnusedDisableDirectives: true
|
||||
},
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
files: ["**/*.ts"],
|
||||
extends: [
|
||||
...tseslint.configs.recommended,
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
...tseslint.configs.stylisticTypeChecked,
|
||||
],
|
||||
rules: {
|
||||
"@typescript-eslint/array-type": "off",
|
||||
"@typescript-eslint/consistent-type-definitions": "off",
|
||||
"@typescript-eslint/consistent-type-imports": [
|
||||
"warn",
|
||||
{ prefer: "type-imports", fixStyle: "inline-type-imports" },
|
||||
],
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{ argsIgnorePattern: "^_" },
|
||||
],
|
||||
"@typescript-eslint/require-await": "off",
|
||||
"@typescript-eslint/no-misused-promises": [
|
||||
"error",
|
||||
{ checksVoidReturn: { attributes: false } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
linterOptions: {
|
||||
reportUnusedDisableDirectives: true,
|
||||
},
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
|
|||
43
flake.lock
generated
|
|
@ -20,11 +20,11 @@
|
|||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1763283776,
|
||||
"narHash": "sha256-Y7TDFPK4GlqrKrivOcsHG8xSGqQx3A6c+i7novT85Uk=",
|
||||
"lastModified": 1774386573,
|
||||
"narHash": "sha256-4hAV26quOxdC6iyG7kYaZcM3VOskcPUrdCQd/nx8obc=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "50a96edd8d0db6cc8db57dab6bb6d6ee1f3dc49a",
|
||||
"rev": "46db2e09e1d3f113a13c0d7b81e2f221c63b8ce9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
@ -34,45 +34,10 @@
|
|||
"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": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"rust-overlay": "rust-overlay"
|
||||
}
|
||||
},
|
||||
"rust-overlay": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1763433504,
|
||||
"narHash": "sha256-cVid5UNpk88sPYHkLAA5aZEHOFQXSB/2L1vl18Aq7IM=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "42ce16c6d8318a654d53f047c9400b7d902d6e61",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"type": "github"
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
|
|
|
|||
111
flake.nix
|
|
@ -1,63 +1,78 @@
|
|||
{
|
||||
description = "CTF Jet development environment (Bun)";
|
||||
|
||||
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";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, rust-overlay, flake-utils }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
outputs =
|
||||
{
|
||||
self,
|
||||
nixpkgs,
|
||||
flake-utils,
|
||||
}:
|
||||
(flake-utils.lib.eachDefaultSystem (
|
||||
system:
|
||||
let
|
||||
overlays = [ (import rust-overlay) ];
|
||||
pkgs = import nixpkgs {
|
||||
inherit system overlays;
|
||||
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
|
||||
./src
|
||||
./tsconfig.json
|
||||
./vite-plugin-ansi.ts
|
||||
./vite.config.ts
|
||||
];
|
||||
};
|
||||
|
||||
bun = pkgs.bun;
|
||||
website = pkgs.buildNpmPackage {
|
||||
pname = "jet-website";
|
||||
version = "0.1.0";
|
||||
src = websiteSrc;
|
||||
npmDepsHash = "sha256-UDz4tXNvEa8uiDDGg16K9JbNeQZR3BsVNKtuOgcyurQ=";
|
||||
|
||||
# Prisma engines for NixOS
|
||||
prismaEngines = pkgs.prisma-engines;
|
||||
|
||||
devTools = with pkgs; [
|
||||
git
|
||||
postgresql
|
||||
curl
|
||||
wget
|
||||
typescript-language-server
|
||||
pkg-config
|
||||
wasm-pack
|
||||
binaryen
|
||||
(rust-bin.selectLatestNightlyWith (toolchain: toolchain.default.override {
|
||||
extensions = [ "rust-src" ];
|
||||
targets = [ "wasm32-unknown-unknown" ];
|
||||
}))
|
||||
];
|
||||
|
||||
in {
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = [
|
||||
bun
|
||||
prismaEngines
|
||||
] ++ devTools;
|
||||
|
||||
NIXPKGS_ALLOW_UNFREE = "1";
|
||||
|
||||
PRISMA_QUERY_ENGINE_BINARY = "${prismaEngines}/bin/query-engine";
|
||||
PRISMA_SCHEMA_ENGINE_BINARY = "${prismaEngines}/bin/schema-engine";
|
||||
PRISMA_INTROSPECTION_ENGINE_BINARY = "${prismaEngines}/bin/introspection-engine";
|
||||
PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING = "1";
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
mkdir -p $out
|
||||
cp -r dist/* $out/
|
||||
runHook postInstall
|
||||
'';
|
||||
};
|
||||
qa-api = pkgs.rustPlatform.buildRustPackage {
|
||||
pname = "jetpham-qa-api";
|
||||
version = "0.1.0";
|
||||
src = ./api;
|
||||
cargoHash = "sha256-mAQUSA5S3uCCo4RbWVgzMMPDjVoSTcJCFdFOx9ZaxQo=";
|
||||
nativeBuildInputs = [ pkgs.pkg-config ];
|
||||
buildInputs = [ pkgs.openssl ];
|
||||
};
|
||||
|
||||
in
|
||||
{
|
||||
packages = {
|
||||
inherit bun prismaEngines;
|
||||
|
||||
default = pkgs.symlinkJoin {
|
||||
name = "ctfjet-dev-bun";
|
||||
paths = [ bun prismaEngines ];
|
||||
};
|
||||
default = website;
|
||||
inherit qa-api;
|
||||
};
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
nodejs
|
||||
git
|
||||
curl
|
||||
openssl
|
||||
typescript-language-server
|
||||
rust-analyzer
|
||||
rustc
|
||||
cargo
|
||||
pkg-config
|
||||
];
|
||||
};
|
||||
}
|
||||
);
|
||||
))
|
||||
// {
|
||||
nixosModules.default = import ./module.nix self;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
137
index.html
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="apple-mobile-web-app-title" content="Jet Pham" />
|
||||
<title>Jet Pham - Software Extremist</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Jet Pham's personal website. Software extremist."
|
||||
/>
|
||||
<link rel="canonical" href="https://jetpham.com/" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/apple-icon.png" />
|
||||
<link
|
||||
rel="preload"
|
||||
href="/Web437_IBM_VGA_8x16.woff"
|
||||
as="font"
|
||||
type="font/woff"
|
||||
crossorigin
|
||||
/>
|
||||
<meta property="og:title" content="Jet Pham - Software Extremist" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Jet Pham's personal website. Software extremist."
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://jetpham.com/" />
|
||||
<meta property="og:site_name" content="Jet Pham" />
|
||||
<meta
|
||||
property="og:image"
|
||||
content="https://jetpham.com/web-app-manifest-512x512.png"
|
||||
/>
|
||||
<meta property="og:image:width" content="512" />
|
||||
<meta property="og:image:height" content="512" />
|
||||
<meta property="og:image:alt" content="Jet Pham" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:title" content="Jet Pham - Software Extremist" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Jet Pham's personal website. Software extremist."
|
||||
/>
|
||||
<meta
|
||||
name="twitter:image"
|
||||
content="https://jetpham.com/web-app-manifest-512x512.png"
|
||||
/>
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Person",
|
||||
"name": "Jet Pham",
|
||||
"givenName": "Jet",
|
||||
"familyName": "Pham",
|
||||
"description": "Software extremist.",
|
||||
"url": "https://jetpham.com",
|
||||
"jobTitle": "Software Extremist",
|
||||
"hasOccupation": {
|
||||
"@type": "Occupation",
|
||||
"name": "Hacker"
|
||||
},
|
||||
"email": "jet@extremist.software",
|
||||
"image": "https://jetpham.com/jet.svg",
|
||||
"alumniOf": {
|
||||
"@type": "CollegeOrUniversity",
|
||||
"name": "University of San Francisco",
|
||||
"url": "https://www.usfca.edu"
|
||||
},
|
||||
"hasCredential": {
|
||||
"@type": "EducationalOccupationalCredential",
|
||||
"credentialCategory": "degree",
|
||||
"name": "Bachelor of Science in Computer Science"
|
||||
},
|
||||
"homeLocation": {
|
||||
"@type": "City",
|
||||
"name": "San Francisco, CA"
|
||||
},
|
||||
"workLocation": {
|
||||
"@type": "City",
|
||||
"name": "San Francisco, CA"
|
||||
},
|
||||
"memberOf": {
|
||||
"@type": "Organization",
|
||||
"name": "Noisebridge",
|
||||
"url": "https://www.noisebridge.net"
|
||||
},
|
||||
"affiliation": {
|
||||
"@type": "Organization",
|
||||
"name": "Noisebridge",
|
||||
"url": "https://www.noisebridge.net"
|
||||
},
|
||||
"sameAs": [
|
||||
"https://github.com/jetpham",
|
||||
"https://x.com/exmistsoftware",
|
||||
"https://bsky.app/profile/extremist.software",
|
||||
"https://git.extremist.software"
|
||||
]
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body style="background: #000">
|
||||
<a class="skip-link" href="#outlet">Skip to content</a>
|
||||
<canvas
|
||||
id="canvas"
|
||||
class="pointer-events-none fixed inset-0 z-0 h-screen w-screen"
|
||||
aria-hidden="true"
|
||||
></canvas>
|
||||
<div class="page-frame relative z-10">
|
||||
<nav aria-label="Main navigation" class="site-region">
|
||||
<div class="site-shell site-panel-frame px-[2ch] py-[1ch]">
|
||||
<div class="site-panel-frost" aria-hidden="true"></div>
|
||||
<div class="site-panel-border" aria-hidden="true"></div>
|
||||
<div
|
||||
class="site-panel-content site-nav-links flex justify-center gap-[2ch]"
|
||||
>
|
||||
<a href="/" data-nav-link class="site-nav-link"
|
||||
><span class="site-nav-marker" aria-hidden="true">></span
|
||||
><span>Home</span
|
||||
><span class="site-nav-marker" aria-hidden="true"><</span></a
|
||||
>
|
||||
<a href="/qa" data-nav-link class="site-nav-link"
|
||||
><span class="site-nav-marker" aria-hidden="true">></span
|
||||
><span>Q&A</span
|
||||
><span class="site-nav-marker" aria-hidden="true"><</span></a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<main id="outlet" class="site-region" tabindex="-1"></main>
|
||||
<footer class="site-region site-footer">
|
||||
<div id="site-footer" class="site-shell"></div>
|
||||
</footer>
|
||||
</div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
36
install.sh
|
|
@ -1,36 +0,0 @@
|
|||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# fix home issues
|
||||
export HOME=/root
|
||||
|
||||
# Install Rustup
|
||||
if ! command -v rustup
|
||||
then
|
||||
echo "Installing Rustup..."
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y -t wasm32-unknown-unknown --profile minimal
|
||||
source "$HOME/.cargo/env"
|
||||
else
|
||||
echo "Rustup already installed."
|
||||
fi
|
||||
|
||||
# Install wasm-pack
|
||||
if ! command -v wasm-pack
|
||||
then
|
||||
echo "Installing wasm-pack..."
|
||||
curl https://drager.github.io/wasm-pack/installer/init.sh -sSf | sh
|
||||
echo "wasm-pack installation complete."
|
||||
else
|
||||
echo "wasm-pack already installed."
|
||||
fi
|
||||
|
||||
# Build cgol WASM package
|
||||
echo "Building cgol WASM package..."
|
||||
cd cgol
|
||||
wasm-pack build --release --target web
|
||||
cd ..
|
||||
|
||||
# Install Next.js dependencies with bun
|
||||
echo "Installing Next.js dependencies with bun..."
|
||||
bun install
|
||||
|
||||
292
module.nix
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
self:
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
|
||||
let
|
||||
cfg = config.services.jetpham-website;
|
||||
package = cfg.package;
|
||||
qaApi = cfg.apiPackage;
|
||||
apiListen = "${cfg.apiListenAddress}:${toString cfg.apiListenPort}";
|
||||
usingDefaultWebhookSecret = cfg.webhookSecretFile == null;
|
||||
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
|
||||
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 {
|
||||
root * ${package}
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
}
|
||||
|
||||
${cfg.caddy.extraConfig}
|
||||
'';
|
||||
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.";
|
||||
};
|
||||
|
||||
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.";
|
||||
};
|
||||
};
|
||||
|
||||
qaNotifyEmail = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "jet@extremist.software";
|
||||
description = "Email address to receive Q&A notifications.";
|
||||
};
|
||||
|
||||
qaMailDomain = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "extremist.software";
|
||||
description = "Mail domain for Q&A reply addresses.";
|
||||
};
|
||||
|
||||
qaReplyDomain = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "extremist.software";
|
||||
description = "Domain used in the static Q&A Reply-To address (`qa@...`). Use a dedicated subdomain and route only that mail into the webhook to avoid impacting your main inbox if the Q&A API fails.";
|
||||
};
|
||||
|
||||
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.";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
age.secrets.webhook-secret = lib.mkIf usingDefaultWebhookSecret {
|
||||
file = "${self}/secrets/webhook-secret.age";
|
||||
mode = "0400";
|
||||
};
|
||||
|
||||
age.secrets.tor-onion-secret-key = lib.mkIf (cfg.tor.enable && usingDefaultTorSecretKey) {
|
||||
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) {
|
||||
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) {
|
||||
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;
|
||||
|
||||
services.tor = lib.mkIf cfg.tor.enable {
|
||||
enable = true;
|
||||
relay.onionServices.jetpham-website = {
|
||||
map = [
|
||||
{
|
||||
port = 80;
|
||||
target = {
|
||||
addr = "127.0.0.1";
|
||||
port = cfg.tor.port;
|
||||
};
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
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";
|
||||
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"
|
||||
chown -R tor:tor "$dir"
|
||||
chmod 700 "$dir"
|
||||
chmod 400 "$dir/hs_ed25519_secret_key"
|
||||
chmod 444 "$dir/hs_ed25519_public_key" "$dir/hostname"
|
||||
'';
|
||||
};
|
||||
|
||||
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";
|
||||
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}";
|
||||
};
|
||||
script = ''
|
||||
if [ ! -s "$CREDENTIALS_DIRECTORY/webhook-secret" ]; then
|
||||
echo "WEBHOOK_SECRET credential is empty" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export WEBHOOK_SECRET="$(cat "$CREDENTIALS_DIRECTORY/webhook-secret")"
|
||||
exec ${qaApi}/bin/jetpham-qa-api
|
||||
'';
|
||||
};
|
||||
|
||||
services.caddy.virtualHosts.${cfg.domain} = lib.mkIf cfg.caddy.enable {
|
||||
extraConfig = caddyCommonConfig;
|
||||
};
|
||||
|
||||
services.caddy.virtualHosts."http://:${toString cfg.tor.port}" =
|
||||
lib.mkIf (cfg.caddy.enable && cfg.tor.enable)
|
||||
{
|
||||
extraConfig = ''
|
||||
bind 127.0.0.1
|
||||
${caddyCommonConfig}
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
100
next.config.js
|
|
@ -1,100 +0,0 @@
|
|||
/**
|
||||
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful
|
||||
* for Docker builds.
|
||||
*/
|
||||
import "./src/env.js";
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const config = {
|
||||
webpack: (config, { isServer }) => {
|
||||
config.module.rules.push({
|
||||
test: /\.txt$/,
|
||||
type: "asset/source",
|
||||
});
|
||||
|
||||
config.experiments = {
|
||||
...config.experiments,
|
||||
asyncWebAssembly: true,
|
||||
};
|
||||
|
||||
config.module.rules.push({
|
||||
test: /\.wasm$/,
|
||||
type: "asset/resource",
|
||||
generator: {
|
||||
filename: "static/wasm/[name].[hash][ext]",
|
||||
},
|
||||
});
|
||||
|
||||
// Ensure WASM files are properly handled
|
||||
config.resolve.fallback = {
|
||||
...config.resolve.fallback,
|
||||
fs: false,
|
||||
};
|
||||
|
||||
// Ensure WASM files are properly served
|
||||
config.output.webassemblyModuleFilename = "static/wasm/[modulehash].wasm";
|
||||
|
||||
return config;
|
||||
},
|
||||
turbopack: {
|
||||
rules: {
|
||||
"*.txt": {
|
||||
loaders: ["raw-loader"],
|
||||
as: "*.js",
|
||||
},
|
||||
},
|
||||
},
|
||||
productionBrowserSourceMaps: false,
|
||||
// Redirect /_not-found to /
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
source: '/_not-found',
|
||||
destination: '/',
|
||||
permanent: false,
|
||||
},
|
||||
];
|
||||
},
|
||||
// Ensure static files are properly served
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/:path*',
|
||||
headers: [
|
||||
{
|
||||
key: 'Cross-Origin-Opener-Policy',
|
||||
value: 'same-origin',
|
||||
},
|
||||
{
|
||||
key: 'Cross-Origin-Resource-Policy',
|
||||
value: 'cross-origin',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
source: '/_next/static/:path*',
|
||||
headers: [
|
||||
{
|
||||
key: 'Cross-Origin-Resource-Policy',
|
||||
value: 'cross-origin',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
source: '/_next/static/wasm/:path*',
|
||||
headers: [
|
||||
{
|
||||
key: 'Cross-Origin-Resource-Policy',
|
||||
value: 'cross-origin',
|
||||
},
|
||||
{
|
||||
key: 'Content-Type',
|
||||
value: 'application/wasm',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
3144
package-lock.json
generated
Normal file
69
package.json
|
|
@ -4,58 +4,29 @@
|
|||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
"build:wasm": "cd cgol && wasm-pack build --release --target web",
|
||||
"check": "next lint && tsc --noEmit",
|
||||
"db:generate": "prisma migrate dev",
|
||||
"db:migrate": "prisma migrate deploy",
|
||||
"db:push": "prisma db push",
|
||||
"db:studio": "prisma studio",
|
||||
"dev": "next dev --turbo",
|
||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||
"postinstall": "prisma generate",
|
||||
"lint": "next lint",
|
||||
"lint:fix": "next lint --fix",
|
||||
"preview": "next build && next start",
|
||||
"start": "next start",
|
||||
"build": "vite build",
|
||||
"check": "npm run lint && tsc --noEmit",
|
||||
"dev": "vite",
|
||||
"format:check": "prettier --check \"**/*.{ts,js,jsx,mdx}\" --cache",
|
||||
"format:write": "prettier --write \"**/*.{ts,js,jsx,mdx}\" --cache",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.19.0",
|
||||
"@t3-oss/env-nextjs": "^0.12.0",
|
||||
"@tanstack/react-query": "^5.90.11",
|
||||
"@trpc/client": "^11.7.2",
|
||||
"@trpc/react-query": "^11.7.2",
|
||||
"@trpc/server": "^11.7.2",
|
||||
"anser": "^2.3.3",
|
||||
"cgol": "file:./cgol/pkg",
|
||||
"escape-carriage": "^1.3.1",
|
||||
"next": "^15.5.6",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"server-only": "^0.0.1",
|
||||
"superjson": "^2.2.5",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@tailwindcss/postcss": "^4.1.17",
|
||||
"@types/node": "^20.19.25",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-next": "^15.5.6",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.14",
|
||||
"prisma": "^6.19.0",
|
||||
"raw-loader": "^4.0.2",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@types/node": "^25.3.3",
|
||||
"anser": "^2.3.5",
|
||||
"escape-carriage": "^1.3.1",
|
||||
"eslint": "^10",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.48.0"
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-singlefile": "^2.3.0"
|
||||
},
|
||||
"ct3aMetadata": {
|
||||
"initVersion": "7.40.0"
|
||||
}
|
||||
"knip": {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
output = "../generated/prisma"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model Post {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([name])
|
||||
}
|
||||
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 979 KiB After Width: | Height: | Size: 979 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
|
@ -16,7 +16,7 @@
|
|||
}
|
||||
],
|
||||
"theme_color": "#a80055",
|
||||
"background_color": "#a80055",
|
||||
"background_color": "#000000",
|
||||
"display": "standalone"
|
||||
}
|
||||
|
||||
0
public/pgp.txt
Normal file
4
public/robots.txt
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://jetpham.com/sitemap.xml
|
||||
18
public/sitemap.xml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://jetpham.com/</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://jetpham.com/projects</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://jetpham.com/qa</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
2
public/ssh.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Jet Pham SSH fingerprints
|
||||
ssh-ed25519 SHA256:Ziw7a2bUA1ew4AFQLB8rk9G3l9I4/eRClf9OJMLMLUA
|
||||
10
secrets.nix
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
let
|
||||
laptop = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIE40ISu3ydCqfdpb26JYD5cIN0Fu0id/FDS+xjB5zpqu";
|
||||
server = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAING219cDKTDLaZefmqvOHfXvYloA/ErsCGE0pM022vlB";
|
||||
in
|
||||
{
|
||||
"secrets/tor-onion-secret-key.age".publicKeys = [ laptop server ];
|
||||
"secrets/tor-onion-public-key.age".publicKeys = [ laptop server ];
|
||||
"secrets/tor-onion-hostname.age".publicKeys = [ laptop server ];
|
||||
"secrets/webhook-secret.age".publicKeys = [ laptop server ];
|
||||
}
|
||||
BIN
secrets/tor-onion-hostname.age
Normal file
7
secrets/tor-onion-public-key.age
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
age-encryption.org/v1
|
||||
-> ssh-ed25519 Ziw7aw 2u0CVE/rQWpJNSRW/1xeWBKpShWT4ckke+Ih4j3WbRk
|
||||
xeE2xTlSPEDPeC4BNkaSoOckwuOhyCQWqtXkwuhBiRo
|
||||
-> ssh-ed25519 uKftJg Tt1mbTWHyXRDjvGWFBqmyrMl/PtUs45N1032luY88x8
|
||||
A51wD3tiZ0lV1TSub+Pz7hZ+kndiEpnmBliP59qYzkY
|
||||
--- 7X+mgLxb3uYfiYebJnAUwl/4jhGJJSweaolMttmoEIQ
|
||||
3à‡’Í.Úu˼’ÞZ"Àä9Ö<39>'AëâªK~.@ŽÌ‰7¾lW„÷ pý{CôÁAŠ&@¸|ÕËšV%55˜Ot¼Šr9Çîºúñ<C3BA>h»L²‡@®G’õLä˜gû€
|
||||
8
secrets/tor-onion-secret-key.age
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
age-encryption.org/v1
|
||||
-> ssh-ed25519 Ziw7aw AZTbqWYmTaudHZ8PiTZlwpf7VzwaP921guVV1iQi8WM
|
||||
7CGUyEjZoAPCBX2pqNHLd2P1KLnj/Y5nnBVToWYCjWg
|
||||
-> ssh-ed25519 uKftJg RfAkte/jcx+/SvZiUNH07cnBJcvl0Sjt7zSxdSCsXE0
|
||||
TdMsQ2u5WXw3KAi7Tk4JOdbiFStT8F88xjDlRN8LH2Q
|
||||
--- pgQnGRRVjVK02tbMgDoh3SatJFxxFLazqy5ieHu96tk
|
||||
žÞxó}ûë¥T¡½<11>SÒP^©kH
׋/æôœõþ›Ý³U~ókÅ;Ö•äÕ†=•ÁºNå«\PZï|óO¨/YíV6Ìx‹=„¹¹Â<gR+2á³C5n
|
||||
©ãÃàÚ/J…‹k¤;5ÚNÈ—Ü#q’&Øxš‹|6²ƒŸ>H
|
||||
7
secrets/webhook-secret.age
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
age-encryption.org/v1
|
||||
-> ssh-ed25519 Ziw7aw Wef4V3554wa7yF3ztMeKxqxgI4sb8MYF7x7GFj+XhFc
|
||||
GcqWkuplBIelOaP1cvOqwyK6igK5MAKPUqvpxCaV/Yk
|
||||
-> ssh-ed25519 uKftJg Tnhs9FR2j2713OO4qDwWb4ERNivqmKI8tN45Av1hzTU
|
||||
bWXnkDFehYUr3AghaUV4wYKfQEOqJsZC/SL2DUcq3DM
|
||||
--- eBpn66oOUDpku3NMzF+30j2uC/iyzO3Oy3lftTQM8MY
|
||||
¡öî™ÙI®í£Q'ã1\óùËÄRç BP®Åº¶nµ_‘pøn„>¬Œƒ“·²‹—k&Xå<58> Ë‚=RL:d6A¨}ÜÒäÎWóK
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
import Anser, { type AnserJsonEntry } from "anser";
|
||||
import { escapeCarriageReturn } from "escape-carriage";
|
||||
import React, { memo, useMemo } from "react";
|
||||
|
||||
const colorMap: Record<string, string> = {
|
||||
"ansi-black": "text-[var(--black)]",
|
||||
"ansi-red": "text-[var(--red)]",
|
||||
"ansi-green": "text-[var(--green)]",
|
||||
"ansi-yellow": "text-[var(--brown)]",
|
||||
"ansi-blue": "text-[var(--blue)]",
|
||||
"ansi-magenta": "text-[var(--magenta)]",
|
||||
"ansi-cyan": "text-[var(--cyan)]",
|
||||
"ansi-white": "text-[var(--light-gray)]",
|
||||
"ansi-bright-black": "text-[var(--dark-gray)]",
|
||||
"ansi-bright-red": "text-[var(--light-red)]",
|
||||
"ansi-bright-green": "text-[var(--light-green)]",
|
||||
"ansi-bright-yellow": "text-[var(--yellow)]",
|
||||
"ansi-bright-blue": "text-[var(--light-blue)]",
|
||||
"ansi-bright-magenta": "text-[var(--light-magenta)]",
|
||||
"ansi-bright-cyan": "text-[var(--light-cyan)]",
|
||||
"ansi-bright-white": "text-[var(--white)]",
|
||||
} as const;
|
||||
|
||||
const bgColorMap: Record<string, string> = {
|
||||
"ansi-black": "bg-transparent",
|
||||
"ansi-red": "bg-[var(--red)]",
|
||||
"ansi-green": "bg-[var(--green)]",
|
||||
"ansi-yellow": "bg-[var(--brown)]",
|
||||
"ansi-blue": "bg-[var(--blue)]",
|
||||
"ansi-magenta": "bg-[var(--magenta)]",
|
||||
"ansi-cyan": "bg-[var(--cyan)]",
|
||||
"ansi-white": "bg-[var(--light-gray)]",
|
||||
"ansi-bright-black": "bg-[var(--dark-gray)]",
|
||||
"ansi-bright-red": "bg-[var(--light-red)]",
|
||||
"ansi-bright-green": "bg-[var(--light-green)]",
|
||||
"ansi-bright-yellow": "bg-[var(--yellow)]",
|
||||
"ansi-bright-blue": "bg-[var(--light-blue)]",
|
||||
"ansi-bright-magenta": "bg-[var(--light-magenta)]",
|
||||
"ansi-bright-cyan": "bg-[var(--light-cyan)]",
|
||||
"ansi-bright-white": "bg-[var(--white)]",
|
||||
} as const;
|
||||
|
||||
const decorationMap: Record<string, string> = {
|
||||
bold: "font-bold",
|
||||
dim: "opacity-50",
|
||||
italic: "italic",
|
||||
hidden: "invisible",
|
||||
strikethrough: "line-through",
|
||||
underline: "underline",
|
||||
blink: "animate-pulse",
|
||||
} as const;
|
||||
|
||||
function fixBackspace(txt: string): string {
|
||||
let tmp = txt;
|
||||
do {
|
||||
txt = tmp;
|
||||
tmp = txt.replace(/[^\n]\x08/gm, "");
|
||||
} while (tmp.length < txt.length);
|
||||
return txt;
|
||||
}
|
||||
|
||||
function createClass(bundle: AnserJsonEntry): string | null {
|
||||
const classes: string[] = [];
|
||||
|
||||
if (bundle.bg && bgColorMap[bundle.bg]) {
|
||||
classes.push(bgColorMap[bundle.bg]!);
|
||||
}
|
||||
if (bundle.fg && colorMap[bundle.fg]) {
|
||||
classes.push(colorMap[bundle.fg]!);
|
||||
}
|
||||
if (bundle.decoration && decorationMap[bundle.decoration]) {
|
||||
classes.push(decorationMap[bundle.decoration]!);
|
||||
}
|
||||
return classes.length ? classes.join(" ") : null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
children?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Ansi = memo(function Ansi({ className, children = "" }: Props) {
|
||||
const bundles = useMemo(() => {
|
||||
const input = escapeCarriageReturn(fixBackspace(children));
|
||||
return Anser.ansiToJson(input, {
|
||||
json: true,
|
||||
remove_empty: true,
|
||||
use_classes: true,
|
||||
});
|
||||
}, [children]);
|
||||
|
||||
const renderedContent = useMemo(
|
||||
() =>
|
||||
bundles.map((bundle, key) => {
|
||||
const bundleClassName = createClass(bundle);
|
||||
return (
|
||||
<span key={key} className={bundleClassName ?? undefined}>
|
||||
{bundle.content}
|
||||
</span>
|
||||
);
|
||||
}),
|
||||
[bundles],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<pre className={className ?? ""} style={{ textAlign: "left" }}>
|
||||
<code>{renderedContent}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default Ansi;
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
import { type ReactNode } from "react";
|
||||
|
||||
interface BorderedBoxProps {
|
||||
label?: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function BorderedBox({
|
||||
label,
|
||||
children,
|
||||
className = "",
|
||||
}: BorderedBoxProps) {
|
||||
return (
|
||||
<fieldset
|
||||
className={`mt-[2ch] border-2 border-white px-[calc(1.5ch-0.5px)] pb-[1ch] pt-0 ${className}`}
|
||||
>
|
||||
{label && (
|
||||
<legend className="-mx-[0.5ch] px-[0.5ch] text-white">
|
||||
{label}
|
||||
</legend>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
|
||||
export function CgolCanvas() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const initializedRef = useRef(false);
|
||||
const cleanupRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const initializeWasm = useCallback(async () => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
try {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || initializedRef.current) return;
|
||||
|
||||
const cgolModule = await import("cgol");
|
||||
|
||||
// Initialize WASM module
|
||||
const initFunction = cgolModule.default;
|
||||
if (initFunction && typeof initFunction === "function") {
|
||||
await initFunction();
|
||||
}
|
||||
|
||||
// Start CGOL
|
||||
if (typeof cgolModule.start === "function") {
|
||||
cgolModule.start();
|
||||
initializedRef.current = true;
|
||||
|
||||
const cleanupFn = (cgolModule as { cleanup?: () => void }).cleanup;
|
||||
if (typeof cleanupFn === "function") {
|
||||
cleanupRef.current = cleanupFn;
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to initialize CGOL WebAssembly module:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize immediately without delay
|
||||
void initializeWasm();
|
||||
|
||||
return () => {
|
||||
// Call cleanup if available from WASM module
|
||||
if (cleanupRef.current) {
|
||||
cleanupRef.current();
|
||||
}
|
||||
// Reset initialization state on unmount
|
||||
initializedRef.current = false;
|
||||
};
|
||||
}, [initializeWasm]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
id="canvas"
|
||||
className="fixed top-0 left-0 -z-10 h-screen w-screen"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
import { type ReactNode } from "react";
|
||||
|
||||
interface FrostedBoxProps {
|
||||
label?: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FrostedBox({
|
||||
label,
|
||||
children,
|
||||
className = "",
|
||||
}: FrostedBoxProps) {
|
||||
return (
|
||||
<div
|
||||
className={`relative my-[calc(2ch-2px)] px-[2ch] py-[2ch] ${className}`}
|
||||
>
|
||||
{/* Extended frosted glass backdrop with mask */}
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 h-[200%]"
|
||||
style={{
|
||||
backgroundColor: "rgba(0, 0, 0, 0.75)",
|
||||
backdropFilter: "blur(16px)",
|
||||
WebkitBackdropFilter: "blur(16px)",
|
||||
maskImage:
|
||||
"linear-gradient(to bottom, black 0% 50%, transparent 50% 100%)",
|
||||
WebkitMaskImage:
|
||||
"linear-gradient(to bottom, black 0% 50%, transparent 50% 100%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Border */}
|
||||
<div className="absolute inset-0 border-2 border-white" />
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10">
|
||||
{label && (
|
||||
<span className="absolute -top-[1ch] left-2 bg-transparent text-white">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
import React from "react";
|
||||
import Ansi from "./ansi";
|
||||
|
||||
interface HeaderProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Header({ content, className }: HeaderProps) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<Ansi>{content}</Ansi>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
export function LatestPost() {
|
||||
const [latestPost] = api.post.getLatest.useSuspenseQuery();
|
||||
|
||||
const utils = api.useUtils();
|
||||
const [name, setName] = useState("");
|
||||
const createPost = api.post.create.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.post.invalidate();
|
||||
setName("");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-xs">
|
||||
{latestPost ? (
|
||||
<p className="truncate">Your most recent post: {latestPost.name}</p>
|
||||
) : (
|
||||
<p>You have no posts yet.</p>
|
||||
)}
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
createPost.mutate({ name });
|
||||
}}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Title"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full rounded-full bg-white/10 px-4 py-2 text-white"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-full bg-white/10 px-10 py-3 font-semibold transition hover:bg-white/20"
|
||||
disabled={createPost.isPending}
|
||||
>
|
||||
{createPost.isPending ? "Submitting..." : "Submit"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
|
||||
import { type NextRequest } from "next/server";
|
||||
|
||||
import { env } from "~/env";
|
||||
import { appRouter } from "~/server/api/root";
|
||||
import { createTRPCContext } from "~/server/api/trpc";
|
||||
|
||||
/**
|
||||
* This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when
|
||||
* handling a HTTP request (e.g. when you make requests from Client Components).
|
||||
*/
|
||||
const createContext = async (req: NextRequest) => {
|
||||
return createTRPCContext({
|
||||
headers: req.headers,
|
||||
});
|
||||
};
|
||||
|
||||
const handler = (req: NextRequest) =>
|
||||
fetchRequestHandler({
|
||||
endpoint: "/api/trpc",
|
||||
req,
|
||||
router: appRouter,
|
||||
createContext: () => createContext(req),
|
||||
onError:
|
||||
env.NODE_ENV === "development"
|
||||
? ({ path, error }) => {
|
||||
console.error(
|
||||
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import "~/styles/globals.css";
|
||||
|
||||
import { type Metadata, type Viewport } from "next";
|
||||
|
||||
// import { TRPCReactProvider } from "~/trpc/react";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Jet Pham",
|
||||
description: "Jet Pham's personal website",
|
||||
icons: [{ rel: "icon", url: "/favicon.ico" }],
|
||||
appleWebApp: {
|
||||
title: "Jet Pham",
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
themeColor: "#000000",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
{/* <TRPCReactProvider> */}
|
||||
{children}
|
||||
{/* </TRPCReactProvider> */}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
// import { HydrateClient } from "~/trpc/server";
|
||||
import { BorderedBox } from "./_components/bordered-box";
|
||||
import { FrostedBox } from "./_components/frosted-box";
|
||||
import Header from "./_components/header";
|
||||
import { CgolCanvas } from "./_components/cgol-canvas";
|
||||
import FirstName from "~/assets/Jet.txt";
|
||||
|
||||
export default async function Home() {
|
||||
return (
|
||||
// <HydrateClient>
|
||||
<>
|
||||
<CgolCanvas />
|
||||
<main>
|
||||
<div className="flex flex-col items-center justify-start px-4">
|
||||
<FrostedBox className="my-[2ch] w-full max-w-[66.666667%] min-w-fit md:mt-[4ch]">
|
||||
<div className="flex flex-col items-center justify-center gap-[2ch] md:flex-row">
|
||||
<div className="order-1 flex flex-col items-center md:order-2">
|
||||
<Header content={FirstName} />
|
||||
<div className="mt-[2ch]">Software Extremist</div>
|
||||
</div>
|
||||
<div className="order-2 shrink-0 md:order-1">
|
||||
<Image
|
||||
src="/jet.svg"
|
||||
alt="Jet"
|
||||
width={250}
|
||||
height={250}
|
||||
className="aspect-square w-full max-w-[250px] object-cover md:h-[263px] md:w-[175px] md:max-w-none"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<BorderedBox label="Skills" className="mt-[2ch]">
|
||||
<div>Making crazy stuff</div>
|
||||
</BorderedBox>
|
||||
<BorderedBox label="Links">
|
||||
<ol>
|
||||
<li>
|
||||
<Link
|
||||
href="https://github.com/jetpham"
|
||||
className="inline-flex items-center"
|
||||
>
|
||||
GitHub
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="https://linkedin.com/in/jetpham"
|
||||
className="inline-flex items-center"
|
||||
>
|
||||
LinkedIn
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="https://bsky.app/profile/jetpham.com"
|
||||
className="inline-flex items-center"
|
||||
>
|
||||
Bluesky
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="https://x.com/jetpham5"
|
||||
className="inline-flex items-center"
|
||||
>
|
||||
X (Twitter)
|
||||
</Link>
|
||||
</li>
|
||||
</ol>
|
||||
</BorderedBox>
|
||||
</FrostedBox>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
// </HydrateClient>
|
||||
);
|
||||
}
|
||||
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>`;
|
||||
}
|
||||
44
src/env.js
|
|
@ -1,44 +0,0 @@
|
|||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import { z } from "zod";
|
||||
|
||||
export const env = createEnv({
|
||||
/**
|
||||
* Specify your server-side environment variables schema here. This way you can ensure the app
|
||||
* isn't built with invalid env vars.
|
||||
*/
|
||||
server: {
|
||||
DATABASE_URL: z.string().url(),
|
||||
NODE_ENV: z
|
||||
.enum(["development", "test", "production"])
|
||||
.default("development"),
|
||||
},
|
||||
|
||||
/**
|
||||
* Specify your client-side environment variables schema here. This way you can ensure the app
|
||||
* isn't built with invalid env vars. To expose them to the client, prefix them with
|
||||
* `NEXT_PUBLIC_`.
|
||||
*/
|
||||
client: {
|
||||
// NEXT_PUBLIC_CLIENTVAR: z.string(),
|
||||
},
|
||||
|
||||
/**
|
||||
* You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
|
||||
* middlewares) or client-side so we need to destruct manually.
|
||||
*/
|
||||
runtimeEnv: {
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
|
||||
},
|
||||
/**
|
||||
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
|
||||
* useful for Docker builds.
|
||||
*/
|
||||
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
|
||||
/**
|
||||
* Makes it so that empty strings are treated as undefined. `SOME_VAR: z.string()` and
|
||||
* `SOME_VAR=''` will throw an error.
|
||||
*/
|
||||
emptyStringAsUndefined: true,
|
||||
});
|
||||
10
src/global.d.ts
vendored
|
|
@ -1,10 +1,16 @@
|
|||
declare module "*.txt" {
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*.txt?raw" {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module "*.utf8ans" {
|
||||
declare module "*.txt?ansi" {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module "*.utf8ans?raw" {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
|
|
|||
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");
|
||||
}
|
||||
}
|
||||
88
src/lib/qa.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
const DRAFT_KEY = "qa-draft";
|
||||
const LAST_PLACEHOLDER_KEY = "qa-last-placeholder";
|
||||
const relativeTime = new Intl.RelativeTimeFormat(undefined, {
|
||||
numeric: "auto",
|
||||
});
|
||||
|
||||
function getValidDate(dateString: string): Date | null {
|
||||
const date = new Date(dateString);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
export function readQuestionDraft(): string {
|
||||
try {
|
||||
return localStorage.getItem(DRAFT_KEY) ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function writeQuestionDraft(value: string) {
|
||||
try {
|
||||
localStorage.setItem(DRAFT_KEY, value);
|
||||
} catch {
|
||||
// Ignore storage failures.
|
||||
}
|
||||
}
|
||||
|
||||
export function clearQuestionDraft() {
|
||||
try {
|
||||
localStorage.removeItem(DRAFT_KEY);
|
||||
} catch {
|
||||
// Ignore storage failures.
|
||||
}
|
||||
}
|
||||
|
||||
export function formatDateOnly(dateString: string): string {
|
||||
const date = getValidDate(dateString);
|
||||
if (!date) return dateString;
|
||||
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function formatFriendlyDate(dateString: string): string {
|
||||
const date = getValidDate(dateString);
|
||||
if (!date) return dateString;
|
||||
|
||||
const now = Date.now();
|
||||
const diffMs = date.getTime() - now;
|
||||
const diffMinutes = Math.round(diffMs / 60000);
|
||||
const diffHours = Math.round(diffMs / 3600000);
|
||||
const diffDays = Math.round(diffMs / 86400000);
|
||||
|
||||
if (Math.abs(diffMinutes) < 60)
|
||||
return relativeTime.format(diffMinutes, "minute");
|
||||
if (Math.abs(diffHours) < 24) return relativeTime.format(diffHours, "hour");
|
||||
if (Math.abs(diffDays) < 14) return relativeTime.format(diffDays, "day");
|
||||
|
||||
return formatDateOnly(dateString);
|
||||
}
|
||||
|
||||
export function pickPlaceholder<T>(items: readonly T[]): T {
|
||||
if (items.length === 1) return items[0]!;
|
||||
|
||||
let lastIndex = -1;
|
||||
try {
|
||||
lastIndex = Number.parseInt(
|
||||
sessionStorage.getItem(LAST_PLACEHOLDER_KEY) ?? "",
|
||||
10,
|
||||
);
|
||||
} catch {
|
||||
lastIndex = -1;
|
||||
}
|
||||
|
||||
let nextIndex = Math.floor(Math.random() * items.length);
|
||||
if (nextIndex === lastIndex) {
|
||||
nextIndex =
|
||||
(nextIndex + 1 + Math.floor(Math.random() * (items.length - 1))) %
|
||||
items.length;
|
||||
}
|
||||
|
||||
try {
|
||||
sessionStorage.setItem(LAST_PLACEHOLDER_KEY, String(nextIndex));
|
||||
} catch {
|
||||
// Ignore storage failures.
|
||||
}
|
||||
|
||||
return items[nextIndex]!;
|
||||
}
|
||||
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>`;
|
||||
}
|
||||
1539
src/lib/webgl-background.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();
|
||||
68
src/pages/home.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import Jet from "~/assets/Jet.txt?ansi";
|
||||
import { frostedBox } from "~/components/frosted-box";
|
||||
|
||||
export function homePage(outlet: HTMLElement) {
|
||||
outlet.classList.remove("qa-outlet");
|
||||
outlet.innerHTML = `
|
||||
<div class="flex flex-col items-center justify-start">
|
||||
${frostedBox(`
|
||||
<div class="flex flex-col items-center justify-center gap-[1.25ch] md:gap-[2ch] md:flex-row">
|
||||
<div class="order-1 flex flex-col items-center md:order-2">
|
||||
<h1 class="sr-only">Jet Pham</h1>
|
||||
<div aria-hidden="true" data-emitter-ansi>${Jet}</div>
|
||||
<p class="mt-[2ch]">Software Extremist</p>
|
||||
</div>
|
||||
<div class="order-2 shrink-0 md:order-1">
|
||||
<img
|
||||
data-emitter-image
|
||||
src="/jet.svg"
|
||||
alt="A picture of Jet wearing a beanie in purple and blue lighting"
|
||||
width="250"
|
||||
height="250"
|
||||
class="aspect-square w-full max-w-[220px] object-cover md:h-[263px] md:w-[175px] md:max-w-none"
|
||||
style="background-color: #a80055; color: transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<fieldset class="section-block mt-[2ch] border-2 px-[calc(1.5ch-0.5px)] pb-[1ch] pt-0" style="border-color: var(--white)">
|
||||
<legend class="-mx-[0.5ch] px-[0.5ch]" style="color: var(--white)">Contact</legend>
|
||||
<button type="button" id="copy-email" class="qa-inline-action">jet@extremist.software</button>
|
||||
<span id="copy-email-status" class="qa-meta ml-[1ch]" aria-live="polite"></span>
|
||||
</fieldset>
|
||||
<fieldset class="section-block mt-[2ch] border-2 px-[calc(1.5ch-0.5px)] pb-[1ch] pt-0" style="border-color: var(--white)">
|
||||
<legend class="-mx-[0.5ch] px-[0.5ch]" style="color: var(--white)">Links</legend>
|
||||
<ol>
|
||||
<li><a href="https://git.extremist.software" class="inline-flex items-center">Forgejo</a></li>
|
||||
<li><a href="https://github.com/jetpham" class="inline-flex items-center">GitHub</a></li>
|
||||
<li><a href="https://x.com/exmistsoftware" class="inline-flex items-center">X</a></li>
|
||||
<li><a href="https://bsky.app/profile/extremist.software" class="inline-flex items-center">Bluesky</a></li>
|
||||
</ol>
|
||||
</fieldset>
|
||||
`)}
|
||||
</div>`;
|
||||
|
||||
const copyButton = document.getElementById("copy-email") as HTMLButtonElement;
|
||||
const copyStatus = document.getElementById(
|
||||
"copy-email-status",
|
||||
) as HTMLSpanElement;
|
||||
let resetTimer: number | null = null;
|
||||
|
||||
copyButton.addEventListener("click", () => {
|
||||
void (async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText("jet@extremist.software");
|
||||
copyStatus.textContent = "copied";
|
||||
copyStatus.style.color = "var(--light-green)";
|
||||
} catch {
|
||||
copyStatus.textContent = "copy failed";
|
||||
copyStatus.style.color = "var(--light-red)";
|
||||
}
|
||||
|
||||
if (resetTimer !== null) window.clearTimeout(resetTimer);
|
||||
resetTimer = window.setTimeout(() => {
|
||||
copyStatus.textContent = "";
|
||||
resetTimer = null;
|
||||
}, 1400);
|
||||
})();
|
||||
});
|
||||
}
|
||||
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
|
|
@ -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();
|
||||
}
|
||||
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,23 +0,0 @@
|
|||
import { postRouter } from "~/server/api/routers/post";
|
||||
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
|
||||
|
||||
/**
|
||||
* This is the primary router for your server.
|
||||
*
|
||||
* All routers added in /api/routers should be manually added here.
|
||||
*/
|
||||
export const appRouter = createTRPCRouter({
|
||||
post: postRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
/**
|
||||
* Create a server-side caller for the tRPC API.
|
||||
* @example
|
||||
* const trpc = createCaller(createContext);
|
||||
* const res = await trpc.post.all();
|
||||
* ^? Post[]
|
||||
*/
|
||||
export const createCaller = createCallerFactory(appRouter);
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
|
||||
|
||||
export const postRouter = createTRPCRouter({
|
||||
hello: publicProcedure
|
||||
.input(z.object({ text: z.string() }))
|
||||
.query(({ input }) => {
|
||||
return {
|
||||
greeting: `Hello ${input.text}`,
|
||||
};
|
||||
}),
|
||||
|
||||
create: publicProcedure
|
||||
.input(z.object({ name: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return ctx.db.post.create({
|
||||
data: {
|
||||
name: input.name,
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
getLatest: publicProcedure.query(async ({ ctx }) => {
|
||||
const post = await ctx.db.post.findFirst({
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
return post ?? null;
|
||||
}),
|
||||
});
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
/**
|
||||
* YOU PROBABLY DON'T NEED TO EDIT THIS FILE, UNLESS:
|
||||
* 1. You want to modify request context (see Part 1).
|
||||
* 2. You want to create a new middleware or type of procedure (see Part 3).
|
||||
*
|
||||
* TL;DR - This is where all the tRPC server stuff is created and plugged in. The pieces you will
|
||||
* need to use are documented accordingly near the end.
|
||||
*/
|
||||
import { initTRPC } from "@trpc/server";
|
||||
import superjson from "superjson";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
import { db } from "~/server/db";
|
||||
|
||||
/**
|
||||
* 1. CONTEXT
|
||||
*
|
||||
* This section defines the "contexts" that are available in the backend API.
|
||||
*
|
||||
* These allow you to access things when processing a request, like the database, the session, etc.
|
||||
*
|
||||
* This helper generates the "internals" for a tRPC context. The API handler and RSC clients each
|
||||
* wrap this and provides the required context.
|
||||
*
|
||||
* @see https://trpc.io/docs/server/context
|
||||
*/
|
||||
export const createTRPCContext = async (opts: { headers: Headers }) => {
|
||||
return {
|
||||
db,
|
||||
...opts,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 2. INITIALIZATION
|
||||
*
|
||||
* This is where the tRPC API is initialized, connecting the context and transformer. We also parse
|
||||
* ZodErrors so that you get typesafety on the frontend if your procedure fails due to validation
|
||||
* errors on the backend.
|
||||
*/
|
||||
const t = initTRPC.context<typeof createTRPCContext>().create({
|
||||
transformer: superjson,
|
||||
errorFormatter({ shape, error }) {
|
||||
return {
|
||||
...shape,
|
||||
data: {
|
||||
...shape.data,
|
||||
zodError:
|
||||
error.cause instanceof ZodError ? error.cause.flatten() : null,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a server-side caller.
|
||||
*
|
||||
* @see https://trpc.io/docs/server/server-side-calls
|
||||
*/
|
||||
export const createCallerFactory = t.createCallerFactory;
|
||||
|
||||
/**
|
||||
* 3. ROUTER & PROCEDURE (THE IMPORTANT BIT)
|
||||
*
|
||||
* These are the pieces you use to build your tRPC API. You should import these a lot in the
|
||||
* "/src/server/api/routers" directory.
|
||||
*/
|
||||
|
||||
/**
|
||||
* This is how you create new routers and sub-routers in your tRPC API.
|
||||
*
|
||||
* @see https://trpc.io/docs/router
|
||||
*/
|
||||
export const createTRPCRouter = t.router;
|
||||
|
||||
/**
|
||||
* Middleware for timing procedure execution and adding an artificial delay in development.
|
||||
*
|
||||
* You can remove this if you don't like it, but it can help catch unwanted waterfalls by simulating
|
||||
* network latency that would occur in production but not in local development.
|
||||
*/
|
||||
const timingMiddleware = t.middleware(async ({ next, path }) => {
|
||||
const start = Date.now();
|
||||
|
||||
if (t._config.isDev) {
|
||||
// artificial delay in dev
|
||||
const waitMs = Math.floor(Math.random() * 400) + 100;
|
||||
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
||||
}
|
||||
|
||||
const result = await next();
|
||||
|
||||
const end = Date.now();
|
||||
console.log(`[TRPC] ${path} took ${end - start}ms to execute`);
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
/**
|
||||
* Public (unauthenticated) procedure
|
||||
*
|
||||
* This is the base piece you use to build new queries and mutations on your tRPC API. It does not
|
||||
* guarantee that a user querying is authorized, but you can still access user session data if they
|
||||
* are logged in.
|
||||
*/
|
||||
export const publicProcedure = t.procedure.use(timingMiddleware);
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
import { env } from "~/env";
|
||||
import { PrismaClient } from "../../generated/prisma";
|
||||
|
||||
const createPrismaClient = () =>
|
||||
new PrismaClient({
|
||||
log:
|
||||
env.NODE_ENV === "development" ? ["query", "error", "warn"] : ["error"],
|
||||
});
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: ReturnType<typeof createPrismaClient> | undefined;
|
||||
};
|
||||
|
||||
export const db = globalForPrisma.prisma ?? createPrismaClient();
|
||||
|
||||
if (env.NODE_ENV !== "production") globalForPrisma.prisma = db;
|
||||
|
|
@ -9,80 +9,460 @@
|
|||
}
|
||||
|
||||
:root {
|
||||
--font-sans: "IBM VGA", ui-monospace, "SF Mono", "Monaco", "Inconsolata", "Roboto Mono", "Source Code Pro", monospace;
|
||||
--font-mono: "IBM VGA", ui-monospace, "SF Mono", "Monaco", "Inconsolata", "Roboto Mono", "Source Code Pro", monospace;
|
||||
--font-sans:
|
||||
"IBM VGA", ui-monospace, "SF Mono", "Monaco", "Inconsolata", "Roboto Mono",
|
||||
"Source Code Pro", monospace;
|
||||
--font-mono:
|
||||
"IBM VGA", ui-monospace, "SF Mono", "Monaco", "Inconsolata", "Roboto Mono",
|
||||
"Source Code Pro", monospace;
|
||||
|
||||
/* 16-color palette */
|
||||
--black: #000000;
|
||||
--blue: #0000AA;
|
||||
--green: #00AA00;
|
||||
--cyan: #00AAAA;
|
||||
--red: #AA0000;
|
||||
--magenta: #AA00AA;
|
||||
--brown: #AA5500;
|
||||
--light-gray: #AAAAAA;
|
||||
--blue: #0000aa;
|
||||
--green: #00aa00;
|
||||
--cyan: #00aaaa;
|
||||
--red: #aa0000;
|
||||
--magenta: #aa00aa;
|
||||
--brown: #aa5500;
|
||||
--light-gray: #aaaaaa;
|
||||
--dark-gray: #555555;
|
||||
--light-blue: #5555FF;
|
||||
--light-green: #55FF55;
|
||||
--light-cyan: #55FFFF;
|
||||
--light-red: #FF5555;
|
||||
--light-magenta: #FF55FF;
|
||||
--yellow: #FFFF55;
|
||||
--white: #FFFFFF;
|
||||
--light-blue: #5555ff;
|
||||
--light-green: #55ff55;
|
||||
--light-cyan: #55ffff;
|
||||
--light-red: #ff5555;
|
||||
--light-magenta: #ff55ff;
|
||||
--yellow: #ffff55;
|
||||
--white: #ffffff;
|
||||
--panel-bg-alpha: 0.2;
|
||||
--panel-gloss-alpha: 0.05;
|
||||
--panel-border-inset: 0.6rem;
|
||||
--panel-bg: rgba(0, 0, 0, var(--panel-bg-alpha));
|
||||
--panel-blur: 10px;
|
||||
--fallback-glow-a: rgba(85, 255, 255, 0.18);
|
||||
--fallback-glow-b: rgba(85, 85, 255, 0.16);
|
||||
--fallback-glow-c: rgba(255, 85, 85, 0.14);
|
||||
}
|
||||
|
||||
/* Global BBS-style 80-character width constraint - responsive */
|
||||
html {
|
||||
height: 100%;
|
||||
width: min(80ch, 100vw); /* 80 characters wide on desktop, full width on mobile */
|
||||
padding: 1rem;
|
||||
margin: 0 auto;
|
||||
font-family: "IBM VGA", monospace;
|
||||
font-size: 1.25rem; /* Smaller font size for mobile */
|
||||
white-space: pre;
|
||||
line-height: 1;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden; /* Disable scrolling */
|
||||
|
||||
html {
|
||||
min-height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background-color: var(--black);
|
||||
color: var(--white);
|
||||
font-family: "IBM VGA", monospace;
|
||||
font-size: 1.25rem;
|
||||
white-space: normal;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: -20vmax;
|
||||
z-index: -30;
|
||||
background: var(--black);
|
||||
opacity: 0;
|
||||
transform: translate3d(0, 0, 0) scale(1);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
body[data-background-mode="failed"]::before {
|
||||
opacity: 1;
|
||||
animation: fallback-drift 18s linear infinite alternate;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
body[data-background-mode="failed"]::before {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fallback-drift {
|
||||
from {
|
||||
transform: translate3d(-3%, -2%, 0) scale(1);
|
||||
}
|
||||
|
||||
/* Desktop font size */
|
||||
@media (min-width: 768px) {
|
||||
html {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
to {
|
||||
transform: translate3d(3%, 2%, 0) scale(1.12);
|
||||
}
|
||||
|
||||
/* Apply CGA theme to body */
|
||||
body {
|
||||
height: 100%;
|
||||
background-color: var(--black);
|
||||
color: var(--white);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden; /* Disable scrolling */
|
||||
}
|
||||
|
||||
/* Global focus ring styles for all tabbable elements */
|
||||
button:focus,
|
||||
a:focus,
|
||||
input:focus,
|
||||
textarea:focus,
|
||||
select:focus,
|
||||
[tabindex]:focus,
|
||||
[contenteditable]:focus {
|
||||
outline: 2px solid white;
|
||||
outline-offset: -2px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* Link styles - blue without underline */
|
||||
a {
|
||||
color: var(--light-blue);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--blue);
|
||||
|
||||
::selection {
|
||||
background-color: var(--light-blue);
|
||||
color: var(--black);
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: inherit;
|
||||
}
|
||||
|
||||
/* Desktop font size */
|
||||
@media (min-width: 768px) {
|
||||
body {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
.page-frame {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
width: min(80ch, 100vw);
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
gap: 2ch;
|
||||
}
|
||||
|
||||
#outlet {
|
||||
display: block;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#outlet.qa-outlet {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#outlet:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.site-shell {
|
||||
width: 100%;
|
||||
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;
|
||||
}
|
||||
|
||||
.site-panel-frost {
|
||||
position: absolute;
|
||||
inset: var(--panel-border-inset);
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(
|
||||
to bottom,
|
||||
rgba(255, 255, 255, var(--panel-gloss-alpha)),
|
||||
transparent 24%
|
||||
),
|
||||
var(--panel-bg);
|
||||
}
|
||||
|
||||
.site-panel-border {
|
||||
position: absolute;
|
||||
inset: var(--panel-border-inset);
|
||||
border: 2px solid var(--white);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.site-panel-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.site-region {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Global focus ring styles for all tabbable elements */
|
||||
button:focus,
|
||||
a:focus,
|
||||
input:focus,
|
||||
textarea:focus,
|
||||
select:focus,
|
||||
[tabindex]:focus,
|
||||
[contenteditable]:focus {
|
||||
outline: 2px solid white;
|
||||
outline-offset: -2px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* Link styles - blue without underline */
|
||||
a {
|
||||
color: var(--light-blue);
|
||||
text-decoration: none;
|
||||
text-underline-offset: 0.2ch;
|
||||
}
|
||||
|
||||
a:hover,
|
||||
a:focus-visible,
|
||||
a[aria-current="page"] {
|
||||
background-color: var(--light-blue);
|
||||
color: var(--black);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
a[aria-current="page"] {
|
||||
color: var(--yellow);
|
||||
background-color: transparent;
|
||||
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;
|
||||
top: 0;
|
||||
transform: translateY(-150%);
|
||||
padding: 0.5ch 1ch;
|
||||
background: var(--yellow);
|
||||
color: var(--black);
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
transform: translateY(1rem);
|
||||
}
|
||||
|
||||
/* Form inputs */
|
||||
.qa-input-wrap {
|
||||
position: relative;
|
||||
padding: 1ch;
|
||||
background-color: rgba(0, 0, 0, 0.18);
|
||||
border: 2px solid var(--white);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1ch;
|
||||
}
|
||||
|
||||
.qa-page {
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.qa-list-scroll {
|
||||
padding-top: 1.5ch;
|
||||
padding-bottom: 1.5ch;
|
||||
scrollbar-gutter: stable;
|
||||
overscroll-behavior: contain;
|
||||
padding-right: 0.5ch;
|
||||
}
|
||||
|
||||
.qa-list-scroll::-webkit-scrollbar {
|
||||
width: 1ch;
|
||||
}
|
||||
|
||||
.qa-list-scroll::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.qa-list-scroll::-webkit-scrollbar-thumb {
|
||||
background-color: var(--dark-gray);
|
||||
border-left: 1px solid transparent;
|
||||
border-right: 1px solid transparent;
|
||||
}
|
||||
|
||||
.qa-list-scroll::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--light-gray);
|
||||
}
|
||||
|
||||
.qa-stats {
|
||||
color: var(--dark-gray);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.qa-textarea {
|
||||
width: 100%;
|
||||
min-height: 2lh;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
caret-color: var(--white);
|
||||
caret-shape: block;
|
||||
color: var(--light-gray);
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
resize: none;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.qa-textarea::placeholder {
|
||||
color: var(--dark-gray);
|
||||
}
|
||||
|
||||
.qa-input-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1ch;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.qa-bar-text {
|
||||
color: var(--dark-gray);
|
||||
}
|
||||
|
||||
.qa-button {
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: var(--yellow);
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.qa-button:hover {
|
||||
background-color: var(--yellow);
|
||||
color: var(--black);
|
||||
}
|
||||
|
||||
.qa-button-message:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.qa-button:disabled {
|
||||
color: var(--dark-gray);
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.qa-button:disabled:hover {
|
||||
background: transparent;
|
||||
color: var(--dark-gray);
|
||||
}
|
||||
|
||||
.qa-meta {
|
||||
margin-top: 0.5ch;
|
||||
color: var(--dark-gray);
|
||||
}
|
||||
|
||||
.qa-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.qa-list-item {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.qa-list-item + .qa-list-item {
|
||||
margin-top: 1.5ch;
|
||||
padding-top: 1.5ch;
|
||||
border-top: 2px solid var(--white);
|
||||
}
|
||||
|
||||
.qa-list-label {
|
||||
margin-bottom: 1ch;
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.qa-item-meta {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
display: none;
|
||||
padding: 0.75ch 1ch;
|
||||
background-color: rgba(0, 0, 0, 0.9);
|
||||
box-shadow: inset 0 0 0 1px var(--dark-gray);
|
||||
color: var(--light-gray);
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.qa-item:hover .qa-item-meta,
|
||||
.qa-item:focus-within .qa-item-meta,
|
||||
.qa-item:focus .qa-item-meta {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.qa-inline-action {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--light-blue);
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.2ch;
|
||||
}
|
||||
|
||||
.qa-inline-action:hover,
|
||||
.qa-inline-action:focus-visible {
|
||||
background-color: var(--light-blue);
|
||||
color: var(--black);
|
||||
}
|
||||
|
||||
.qa-textarea::selection {
|
||||
background-color: var(--light-blue);
|
||||
color: var(--black);
|
||||
}
|
||||
|
||||
.qa-textarea:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.site-footer-inner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1ch;
|
||||
color: var(--dark-gray);
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Project markdown content */
|
||||
|
|
|
|||
|
|
@ -1,25 +0,0 @@
|
|||
import {
|
||||
defaultShouldDehydrateQuery,
|
||||
QueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
export const createQueryClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
// With SSR, we usually want to set some default staleTime
|
||||
// above 0 to avoid refetching immediately on the client
|
||||
staleTime: 30 * 1000,
|
||||
},
|
||||
dehydrate: {
|
||||
serializeData: SuperJSON.serialize,
|
||||
shouldDehydrateQuery: (query) =>
|
||||
defaultShouldDehydrateQuery(query) ||
|
||||
query.state.status === "pending",
|
||||
},
|
||||
hydrate: {
|
||||
deserializeData: SuperJSON.deserialize,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { QueryClientProvider, type QueryClient } from "@tanstack/react-query";
|
||||
import { httpBatchStreamLink, loggerLink } from "@trpc/client";
|
||||
import { createTRPCReact } from "@trpc/react-query";
|
||||
import { type inferRouterInputs, type inferRouterOutputs } from "@trpc/server";
|
||||
import { useState } from "react";
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
import { type AppRouter } from "~/server/api/root";
|
||||
import { createQueryClient } from "./query-client";
|
||||
|
||||
let clientQueryClientSingleton: QueryClient | undefined = undefined;
|
||||
const getQueryClient = () => {
|
||||
if (typeof window === "undefined") {
|
||||
// Server: always make a new query client
|
||||
return createQueryClient();
|
||||
}
|
||||
// Browser: use singleton pattern to keep the same query client
|
||||
clientQueryClientSingleton ??= createQueryClient();
|
||||
|
||||
return clientQueryClientSingleton;
|
||||
};
|
||||
|
||||
export const api = createTRPCReact<AppRouter>();
|
||||
|
||||
/**
|
||||
* Inference helper for inputs.
|
||||
*
|
||||
* @example type HelloInput = RouterInputs['example']['hello']
|
||||
*/
|
||||
export type RouterInputs = inferRouterInputs<AppRouter>;
|
||||
|
||||
/**
|
||||
* Inference helper for outputs.
|
||||
*
|
||||
* @example type HelloOutput = RouterOutputs['example']['hello']
|
||||
*/
|
||||
export type RouterOutputs = inferRouterOutputs<AppRouter>;
|
||||
|
||||
export function TRPCReactProvider(props: { children: React.ReactNode }) {
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
const [trpcClient] = useState(() =>
|
||||
api.createClient({
|
||||
links: [
|
||||
loggerLink({
|
||||
enabled: (op) =>
|
||||
process.env.NODE_ENV === "development" ||
|
||||
(op.direction === "down" && op.result instanceof Error),
|
||||
}),
|
||||
httpBatchStreamLink({
|
||||
transformer: SuperJSON,
|
||||
url: getBaseUrl() + "/api/trpc",
|
||||
headers: () => {
|
||||
const headers = new Headers();
|
||||
headers.set("x-trpc-source", "nextjs-react");
|
||||
return headers;
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<api.Provider client={trpcClient} queryClient={queryClient}>
|
||||
{props.children}
|
||||
</api.Provider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function getBaseUrl() {
|
||||
if (typeof window !== "undefined") return window.location.origin;
|
||||
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
|
||||
return `http://localhost:${process.env.PORT ?? 3000}`;
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
import "server-only";
|
||||
|
||||
import { createHydrationHelpers } from "@trpc/react-query/rsc";
|
||||
import { headers } from "next/headers";
|
||||
import { cache } from "react";
|
||||
|
||||
import { createCaller, type AppRouter } from "~/server/api/root";
|
||||
import { createTRPCContext } from "~/server/api/trpc";
|
||||
import { createQueryClient } from "./query-client";
|
||||
|
||||
/**
|
||||
* This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when
|
||||
* handling a tRPC call from a React Server Component.
|
||||
*/
|
||||
const createContext = cache(async () => {
|
||||
const heads = new Headers(await headers());
|
||||
heads.set("x-trpc-source", "rsc");
|
||||
|
||||
return createTRPCContext({
|
||||
headers: heads,
|
||||
});
|
||||
});
|
||||
|
||||
const getQueryClient = cache(createQueryClient);
|
||||
const caller = createCaller(createContext);
|
||||
|
||||
export const { trpc: api, HydrateClient } = createHydrationHelpers<AppRouter>(
|
||||
caller,
|
||||
getQueryClient
|
||||
);
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Use this script to start a docker container for a local development database
|
||||
|
||||
# TO RUN ON WINDOWS:
|
||||
# 1. Install WSL (Windows Subsystem for Linux) - https://learn.microsoft.com/en-us/windows/wsl/install
|
||||
# 2. Install Docker Desktop or Podman Deskop
|
||||
# - Docker Desktop for Windows - https://docs.docker.com/docker-for-windows/install/
|
||||
# - Podman Desktop - https://podman.io/getting-started/installation
|
||||
# 3. Open WSL - `wsl`
|
||||
# 4. Run this script - `./start-database.sh`
|
||||
|
||||
# On Linux and macOS you can run this script directly - `./start-database.sh`
|
||||
|
||||
# import env variables from .env
|
||||
set -a
|
||||
source .env
|
||||
|
||||
DB_PASSWORD=$(echo "$DATABASE_URL" | awk -F':' '{print $3}' | awk -F'@' '{print $1}')
|
||||
DB_PORT=$(echo "$DATABASE_URL" | awk -F':' '{print $4}' | awk -F'\/' '{print $1}')
|
||||
DB_NAME=$(echo "$DATABASE_URL" | awk -F'/' '{print $4}')
|
||||
DB_CONTAINER_NAME="$DB_NAME-postgres"
|
||||
|
||||
if ! [ -x "$(command -v docker)" ] && ! [ -x "$(command -v podman)" ]; then
|
||||
echo -e "Docker or Podman is not installed. Please install docker or podman and try again.\nDocker install guide: https://docs.docker.com/engine/install/\nPodman install guide: https://podman.io/getting-started/installation"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# determine which docker command to use
|
||||
if [ -x "$(command -v docker)" ]; then
|
||||
DOCKER_CMD="docker"
|
||||
elif [ -x "$(command -v podman)" ]; then
|
||||
DOCKER_CMD="podman"
|
||||
fi
|
||||
|
||||
if ! $DOCKER_CMD info > /dev/null 2>&1; then
|
||||
echo "$DOCKER_CMD daemon is not running. Please start $DOCKER_CMD and try again."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if command -v nc >/dev/null 2>&1; then
|
||||
if nc -z localhost "$DB_PORT" 2>/dev/null; then
|
||||
echo "Port $DB_PORT is already in use."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "Warning: Unable to check if port $DB_PORT is already in use (netcat not installed)"
|
||||
read -p "Do you want to continue anyway? [y/N]: " -r REPLY
|
||||
if ! [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "Aborting."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$($DOCKER_CMD ps -q -f name=$DB_CONTAINER_NAME)" ]; then
|
||||
echo "Database container '$DB_CONTAINER_NAME' already running"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$($DOCKER_CMD ps -q -a -f name=$DB_CONTAINER_NAME)" ]; then
|
||||
$DOCKER_CMD start "$DB_CONTAINER_NAME"
|
||||
echo "Existing database container '$DB_CONTAINER_NAME' started"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$DB_PASSWORD" = "password" ]; then
|
||||
echo "You are using the default database password"
|
||||
read -p "Should we generate a random password for you? [y/N]: " -r REPLY
|
||||
if ! [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "Please change the default password in the .env file and try again"
|
||||
exit 1
|
||||
fi
|
||||
# Generate a random URL-safe password
|
||||
DB_PASSWORD=$(openssl rand -base64 12 | tr '+/' '-_')
|
||||
if [[ "$(uname)" == "Darwin" ]]; then
|
||||
# macOS requires an empty string to be passed with the `i` flag
|
||||
sed -i '' "s#:password@#:$DB_PASSWORD@#" .env
|
||||
else
|
||||
sed -i "s#:password@#:$DB_PASSWORD@#" .env
|
||||
fi
|
||||
fi
|
||||
|
||||
$DOCKER_CMD run -d \
|
||||
--name $DB_CONTAINER_NAME \
|
||||
-e POSTGRES_USER="postgres" \
|
||||
-e POSTGRES_PASSWORD="$DB_PASSWORD" \
|
||||
-e POSTGRES_DB="$DB_NAME" \
|
||||
-p "$DB_PORT":5432 \
|
||||
docker.io/postgres && echo "Database container '$DB_CONTAINER_NAME' was successfully created"
|
||||
|
|
@ -1,42 +1,17 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
/* Base Options: */
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"target": "es2022",
|
||||
"allowJs": true,
|
||||
"resolveJsonModule": true,
|
||||
"moduleDetection": "force",
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
|
||||
/* Strictness */
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"checkJs": true,
|
||||
|
||||
/* Bundled projects */
|
||||
"lib": ["dom", "dom.iterable", "ES2022"],
|
||||
"noEmit": true,
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "preserve",
|
||||
"plugins": [{ "name": "next" }],
|
||||
"incremental": true,
|
||||
|
||||
/* Path Aliases */
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"skipLibCheck": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"**/*.cjs",
|
||||
"**/*.js",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": ["node_modules", "generated", "cgol/pkg"]
|
||||
"include": ["src", "vite.config.ts", "vite-plugin-ansi.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
|
|
|||
132
vite-plugin-ansi.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import Anser, { type AnserJsonEntry } from "anser";
|
||||
import { escapeCarriageReturn } from "escape-carriage";
|
||||
import fs from "node:fs";
|
||||
import type { Plugin } from "vite";
|
||||
|
||||
const colorMap: Record<string, string> = {
|
||||
"ansi-black": "color: var(--black)",
|
||||
"ansi-red": "color: var(--red)",
|
||||
"ansi-green": "color: var(--green)",
|
||||
"ansi-yellow": "color: var(--brown)",
|
||||
"ansi-blue": "color: var(--blue)",
|
||||
"ansi-magenta": "color: var(--magenta)",
|
||||
"ansi-cyan": "color: var(--cyan)",
|
||||
"ansi-white": "color: var(--light-gray)",
|
||||
"ansi-bright-black": "color: var(--dark-gray)",
|
||||
"ansi-bright-red": "color: var(--light-red)",
|
||||
"ansi-bright-green": "color: var(--light-green)",
|
||||
"ansi-bright-yellow": "color: var(--yellow)",
|
||||
"ansi-bright-blue": "color: var(--light-blue)",
|
||||
"ansi-bright-magenta": "color: var(--light-magenta)",
|
||||
"ansi-bright-cyan": "color: var(--light-cyan)",
|
||||
"ansi-bright-white": "color: var(--white)",
|
||||
};
|
||||
|
||||
const bgColorMap: Record<string, string> = {
|
||||
"ansi-black": "background-color: transparent",
|
||||
"ansi-red": "background-color: var(--red)",
|
||||
"ansi-green": "background-color: var(--green)",
|
||||
"ansi-yellow": "background-color: var(--brown)",
|
||||
"ansi-blue": "background-color: var(--blue)",
|
||||
"ansi-magenta": "background-color: var(--magenta)",
|
||||
"ansi-cyan": "background-color: var(--cyan)",
|
||||
"ansi-white": "background-color: var(--light-gray)",
|
||||
"ansi-bright-black": "background-color: var(--dark-gray)",
|
||||
"ansi-bright-red": "background-color: var(--light-red)",
|
||||
"ansi-bright-green": "background-color: var(--light-green)",
|
||||
"ansi-bright-yellow": "background-color: var(--yellow)",
|
||||
"ansi-bright-blue": "background-color: var(--light-blue)",
|
||||
"ansi-bright-magenta": "background-color: var(--light-magenta)",
|
||||
"ansi-bright-cyan": "background-color: var(--light-cyan)",
|
||||
"ansi-bright-white": "background-color: var(--white)",
|
||||
};
|
||||
|
||||
const decorationMap: Record<string, string> = {
|
||||
bold: "font-weight: 700",
|
||||
dim: "opacity: 0.5",
|
||||
italic: "font-style: italic",
|
||||
hidden: "visibility: hidden",
|
||||
strikethrough: "text-decoration: line-through",
|
||||
underline: "text-decoration: underline",
|
||||
blink: "animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite",
|
||||
};
|
||||
|
||||
function fixBackspace(txt: string): string {
|
||||
let tmp = txt;
|
||||
do {
|
||||
txt = tmp;
|
||||
tmp = txt.replace(/[^\n]\x08/gm, "");
|
||||
} while (tmp.length < txt.length);
|
||||
return txt;
|
||||
}
|
||||
|
||||
function createStyle(bundle: AnserJsonEntry): string | null {
|
||||
const declarations: string[] = [];
|
||||
if (bundle.bg && bgColorMap[bundle.bg]) {
|
||||
declarations.push(bgColorMap[bundle.bg]!);
|
||||
}
|
||||
if (bundle.fg && colorMap[bundle.fg]) {
|
||||
declarations.push(colorMap[bundle.fg]!);
|
||||
}
|
||||
if (bundle.decoration && decorationMap[bundle.decoration]) {
|
||||
declarations.push(decorationMap[bundle.decoration]!);
|
||||
}
|
||||
return declarations.length ? declarations.join("; ") : null;
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function renderAnsiToHtml(raw: string): string {
|
||||
const input = escapeCarriageReturn(fixBackspace(raw));
|
||||
const bundles = Anser.ansiToJson(input, {
|
||||
json: true,
|
||||
remove_empty: true,
|
||||
use_classes: true,
|
||||
});
|
||||
|
||||
const spans = bundles
|
||||
.map((bundle) => {
|
||||
const style = createStyle(bundle);
|
||||
const content = escapeHtml(bundle.content);
|
||||
if (style) {
|
||||
return `<span style="${style}">${content}</span>`;
|
||||
}
|
||||
return `<span>${content}</span>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
return `<div class="flex justify-center"><pre style="text-align:left"><code>${spans}</code></pre></div>`;
|
||||
}
|
||||
|
||||
const ANSI_SUFFIX = "?ansi";
|
||||
|
||||
export default function ansiPlugin(): Plugin {
|
||||
return {
|
||||
name: "vite-plugin-ansi",
|
||||
enforce: "pre",
|
||||
async resolveId(source, importer, options) {
|
||||
if (!source.endsWith(ANSI_SUFFIX)) return;
|
||||
const bare = source.slice(0, -ANSI_SUFFIX.length);
|
||||
const resolved = await this.resolve(bare, importer, {
|
||||
...options,
|
||||
skipSelf: true,
|
||||
});
|
||||
if (resolved) {
|
||||
return resolved.id + ANSI_SUFFIX;
|
||||
}
|
||||
},
|
||||
load(id) {
|
||||
if (!id.endsWith(ANSI_SUFFIX)) return;
|
||||
const filePath = id.slice(0, -ANSI_SUFFIX.length);
|
||||
const raw = fs.readFileSync(filePath, "utf-8");
|
||||
const html = renderAnsiToHtml(raw);
|
||||
return `export default ${JSON.stringify(html)};`;
|
||||
},
|
||||
};
|
||||
}
|
||||
37
vite.config.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { defineConfig } from "vite";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { viteSingleFile } from "vite-plugin-singlefile";
|
||||
import ansi from "./vite-plugin-ansi";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
ansi(),
|
||||
tailwindcss(),
|
||||
viteSingleFile({ useRecommendedBuildConfig: false }),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
"~": "/src",
|
||||
},
|
||||
},
|
||||
server: {
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"Cross-Origin-Opener-Policy": "same-origin",
|
||||
"Cross-Origin-Embedder-Policy": "require-corp",
|
||||
},
|
||||
},
|
||||
preview: {
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"Cross-Origin-Opener-Policy": "same-origin",
|
||||
"Cross-Origin-Embedder-Policy": "require-corp",
|
||||
},
|
||||
},
|
||||
build: {
|
||||
target: "esnext",
|
||||
assetsInlineLimit: 0,
|
||||
cssCodeSplit: false,
|
||||
cssMinify: false,
|
||||
},
|
||||
});
|
||||