init
14
.env.example
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# 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"
|
||||||
1
.envrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
use flake
|
||||||
49
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
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
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# idea files
|
||||||
|
.idea
|
||||||
|
|
||||||
|
/.direnv
|
||||||
|
/generated
|
||||||
81
README.md
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
# jetpham.com
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="src/app/icon0.svg" alt="jetpham.com icon" width="200" height="200">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
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 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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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. Features like the Q+A are inspired directly from my friend Clover's website: ([paperclover.net](https://paperclover.net/)). Go check out her awesome site!
|
||||||
|
|
||||||
|
I have some awesome features packed in this site now that represent all the cool things I'm interested in:
|
||||||
|
- ANSI rendering of my name in css!
|
||||||
|
- Terminal style text, font, and colors just like BBS
|
||||||
|
(To be implemented)
|
||||||
|
- Rust WASM implementation of Conway's Game of Life with Rayon
|
||||||
|
- Super cool blog filled with stuff about me
|
||||||
|
- A sick Q+A inspired from ([paperclover.net](https://paperclover.net/))
|
||||||
|
- Projects page with info about projects I've made
|
||||||
|
- 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)
|
||||||
|
- [NextAuth.js v5](https://next-auth.js.org)
|
||||||
|
- [Prisma](https://prisma.io)
|
||||||
|
- [Tailwind CSS v4](https://tailwindcss.com)
|
||||||
|
- [tRPC](https://trpc.io)
|
||||||
|
- [TypeScript](https://www.typescriptlang.org/)
|
||||||
|
- [React Query](https://tanstack.com/query)
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Bun
|
||||||
|
- Docker
|
||||||
|
|
||||||
|
### Getting Started
|
||||||
|
|
||||||
|
1. Clone the repository
|
||||||
|
2. Install dependencies:
|
||||||
|
```bash
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Set up environment variables:
|
||||||
|
```bash
|
||||||
|
cp .env.example .env.local
|
||||||
|
# Edit .env.local with your configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Set up the database:
|
||||||
|
```bash
|
||||||
|
bun run db:push
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Start the development server:
|
||||||
|
```bash
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app/ # Next.js App Router pages
|
||||||
|
│ ├── _components/ # Reusable UI components
|
||||||
|
│ ├── admin/ # Admin dashboard
|
||||||
|
│ └── api/ # API routes
|
||||||
|
├── server/ # Server-side code
|
||||||
|
│ ├── api/ # tRPC routers
|
||||||
|
│ └── auth/ # Authentication configuration
|
||||||
|
├── styles/ # Global styles
|
||||||
|
└── trpc/ # tRPC client configuration
|
||||||
|
```
|
||||||
2
cgol/.cargo/config.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
[build]
|
||||||
|
target = "wasm32-unknown-unknown"
|
||||||
7
cgol/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
/target
|
||||||
|
**/*.rs.bk
|
||||||
|
Cargo.lock
|
||||||
|
bin/
|
||||||
|
pkg/
|
||||||
|
wasm-pack.log
|
||||||
|
/pkg
|
||||||
3
cgol/.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"rust-analyzer.cargo.target": "wasm32-unknown-unknown",
|
||||||
|
}
|
||||||
49
cgol/Cargo.toml
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
[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.84"
|
||||||
|
js-sys = "0.3"
|
||||||
|
web-sys = { version = "0.3", features = [
|
||||||
|
"console",
|
||||||
|
"CanvasRenderingContext2d",
|
||||||
|
"Document",
|
||||||
|
"HtmlCanvasElement",
|
||||||
|
"Window",
|
||||||
|
"MouseEvent",
|
||||||
|
"Element",
|
||||||
|
"HtmlElement",
|
||||||
|
"EventTarget",
|
||||||
|
"Performance",
|
||||||
|
"DomRect",
|
||||||
|
"CssStyleDeclaration",
|
||||||
|
"TouchEvent",
|
||||||
|
"Touch",
|
||||||
|
"TouchList",
|
||||||
|
"OffscreenCanvas",
|
||||||
|
"OffscreenCanvasRenderingContext2d",
|
||||||
|
"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.7", optional = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
wasm-bindgen-test = "0.3.34"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
# Tell `rustc` to optimize for small code size.
|
||||||
|
opt-level = "z"
|
||||||
|
lto = true
|
||||||
84
cgol/README.md
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
<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.
|
||||||
421
cgol/src/lib.rs
Normal file
|
|
@ -0,0 +1,421 @@
|
||||||
|
mod utils;
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
use wasm_bindgen::prelude::*;
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
|
||||||
|
use js_sys::Math;
|
||||||
|
use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement, Window, OffscreenCanvas, OffscreenCanvasRenderingContext2d, ImageData};
|
||||||
|
|
||||||
|
const CELL_SIZE: u32 = 20;
|
||||||
|
const TARGET_FPS: f64 = 45.0;
|
||||||
|
const FRAME_DURATION_MS: f64 = 1000.0 / TARGET_FPS;
|
||||||
|
|
||||||
|
struct CellCache {
|
||||||
|
ctx: OffscreenCanvasRenderingContext2d,
|
||||||
|
cached_cells: std::collections::HashMap<u8, ImageData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CellCache {
|
||||||
|
fn new() -> Result<Self, JsValue> {
|
||||||
|
let offscreen_canvas = OffscreenCanvas::new(CELL_SIZE, CELL_SIZE)?;
|
||||||
|
let context_options = js_sys::Object::new();
|
||||||
|
js_sys::Reflect::set(&context_options, &"willReadFrequently".into(), &true.into())?;
|
||||||
|
let ctx = offscreen_canvas
|
||||||
|
.get_context_with_context_options("2d", &context_options)?
|
||||||
|
.ok_or_else(|| JsValue::from_str("no 2d context"))?
|
||||||
|
.dyn_into::<OffscreenCanvasRenderingContext2d>()?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
ctx,
|
||||||
|
cached_cells: std::collections::HashMap::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_or_create_cell(&mut self, hue: u8) -> Result<&ImageData, JsValue> {
|
||||||
|
if !self.cached_cells.contains_key(&hue) {
|
||||||
|
self.ctx.set_fill_style_str("#000");
|
||||||
|
self.ctx.fill_rect(0.0, 0.0, CELL_SIZE as f64, CELL_SIZE as f64);
|
||||||
|
|
||||||
|
let hue_degrees = hue as f64 * 360.0 / 255.0;
|
||||||
|
let color_str = format!("hsl({}, 100%, 50%)", hue_degrees);
|
||||||
|
self.ctx.set_fill_style_str(&color_str);
|
||||||
|
self.ctx.fill_rect(0.0, 0.0, CELL_SIZE as f64, CELL_SIZE as f64);
|
||||||
|
|
||||||
|
let image_data = self.ctx.get_image_data(0.0, 0.0, CELL_SIZE as f64, CELL_SIZE as f64)?;
|
||||||
|
self.cached_cells.insert(hue, image_data);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(self.cached_cells.get(&hue).unwrap())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn random_hue() -> u8 {
|
||||||
|
(Math::random() * 256.0) as u8
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mix_colors(hues: Vec<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
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq)]
|
||||||
|
enum Cell {
|
||||||
|
Dead,
|
||||||
|
Alive { hue: u8 },
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Universe {
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
cells: Vec<Cell>,
|
||||||
|
next_cells: Vec<Cell>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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],
|
||||||
|
};
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn index(&self, row: u32, col: u32) -> usize {
|
||||||
|
(row * self.width + col) as usize
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_neighbor_hues(&self, row: u32, col: u32) -> Vec<u8> {
|
||||||
|
let mut hues = Vec::new();
|
||||||
|
|
||||||
|
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] {
|
||||||
|
hues.push(*hue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hues
|
||||||
|
}
|
||||||
|
|
||||||
|
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_hues = self.get_neighbor_hues(row, col);
|
||||||
|
let neighbor_count = neighbor_hues.len() as u8;
|
||||||
|
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(neighbor_hues);
|
||||||
|
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) {
|
||||||
|
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: random_hue() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
struct AppState {
|
||||||
|
window: Window,
|
||||||
|
canvas: HtmlCanvasElement,
|
||||||
|
ctx: CanvasRenderingContext2d,
|
||||||
|
universe: Universe,
|
||||||
|
cursor_row: i32,
|
||||||
|
cursor_col: i32,
|
||||||
|
last_frame_time: f64,
|
||||||
|
cursor_active: bool,
|
||||||
|
cell_cache: CellCache,
|
||||||
|
}
|
||||||
|
|
||||||
|
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>()?;
|
||||||
|
|
||||||
|
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,
|
||||||
|
cell_cache: CellCache::new()?,
|
||||||
|
};
|
||||||
|
|
||||||
|
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 style = self.canvas.style();
|
||||||
|
style.set_property("position", "fixed").ok();
|
||||||
|
style.set_property("inset", "0").ok();
|
||||||
|
style.set_property("width", &format!("{}px", css_w)).ok();
|
||||||
|
style.set_property("height", &format!("{}px", css_h)).ok();
|
||||||
|
self.canvas.set_width((css_w * dpr) as u32);
|
||||||
|
self.canvas.set_height((css_h * dpr) as u32);
|
||||||
|
self.ctx.set_image_smoothing_enabled(false);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw(&mut self) {
|
||||||
|
self.ctx.set_fill_style_str("#000");
|
||||||
|
self.ctx.fill_rect(0.0, 0.0, self.canvas.width() as f64, self.canvas.height() as f64);
|
||||||
|
|
||||||
|
let cell_w = CELL_SIZE as f64;
|
||||||
|
let cell_h = CELL_SIZE as f64;
|
||||||
|
|
||||||
|
for row in 0..self.universe.height {
|
||||||
|
for col in 0..self.universe.width {
|
||||||
|
let idx = self.universe.index(row, col);
|
||||||
|
if let Cell::Alive { hue } = self.universe.cells[idx] {
|
||||||
|
let x = col as f64 * cell_w;
|
||||||
|
let y = row as f64 * cell_h;
|
||||||
|
|
||||||
|
if let Ok(image_data) = self.cell_cache.get_or_create_cell(hue) {
|
||||||
|
self.ctx.put_image_data(image_data, x, y).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn start() -> Result<(), JsValue> {
|
||||||
|
let state = AppState::new()?;
|
||||||
|
|
||||||
|
{
|
||||||
|
let canvas = state.canvas.clone();
|
||||||
|
let window = state.window.clone();
|
||||||
|
let state_rc = Rc::new(RefCell::new(state));
|
||||||
|
let state_for_mouse = state_rc.clone();
|
||||||
|
let mouse_closure = Closure::wrap(Box::new(move |event: web_sys::MouseEvent| {
|
||||||
|
let rect = canvas.get_bounding_client_rect();
|
||||||
|
let dpr = window.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(_)>);
|
||||||
|
state_rc
|
||||||
|
.borrow()
|
||||||
|
.window
|
||||||
|
.document()
|
||||||
|
.unwrap()
|
||||||
|
.add_event_listener_with_callback("mousemove", mouse_closure.as_ref().unchecked_ref())?;
|
||||||
|
mouse_closure.forget();
|
||||||
|
|
||||||
|
let canvas_touch_move = state_rc.borrow().canvas.clone();
|
||||||
|
let window_touch_move = state_rc.borrow().window.clone();
|
||||||
|
let state_for_touch_move = state_rc.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_touch_move.get_bounding_client_rect();
|
||||||
|
let dpr = window_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(_)>);
|
||||||
|
|
||||||
|
let canvas_touch_start = state_rc.borrow().canvas.clone();
|
||||||
|
let window_touch_start = state_rc.borrow().window.clone();
|
||||||
|
let state_for_touch_start = state_rc.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_touch_start.get_bounding_client_rect();
|
||||||
|
let dpr = window_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(_)>);
|
||||||
|
|
||||||
|
state_rc
|
||||||
|
.borrow()
|
||||||
|
.window
|
||||||
|
.document()
|
||||||
|
.unwrap()
|
||||||
|
.add_event_listener_with_callback("touchmove", touch_move_closure.as_ref().unchecked_ref())?;
|
||||||
|
state_rc
|
||||||
|
.borrow()
|
||||||
|
.window
|
||||||
|
.document()
|
||||||
|
.unwrap()
|
||||||
|
.add_event_listener_with_callback("touchstart", touch_start_closure.as_ref().unchecked_ref())?;
|
||||||
|
|
||||||
|
touch_move_closure.forget();
|
||||||
|
touch_start_closure.forget();
|
||||||
|
|
||||||
|
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(_)>);
|
||||||
|
|
||||||
|
state_rc
|
||||||
|
.borrow()
|
||||||
|
.window
|
||||||
|
.document()
|
||||||
|
.unwrap()
|
||||||
|
.add_event_listener_with_callback("touchend", touch_end_closure.as_ref().unchecked_ref())?;
|
||||||
|
touch_end_closure.forget();
|
||||||
|
|
||||||
|
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()>);
|
||||||
|
state_rc
|
||||||
|
.borrow()
|
||||||
|
.window
|
||||||
|
.add_event_listener_with_callback("resize", resize_closure.as_ref().unchecked_ref())?;
|
||||||
|
resize_closure.forget();
|
||||||
|
|
||||||
|
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 = state_rc.borrow().window.clone();
|
||||||
|
*g.borrow_mut() = Some(Closure::wrap(Box::new(move || {
|
||||||
|
let current_time = state_for_anim.borrow().get_current_time();
|
||||||
|
let mut should_update = false;
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut s = state_for_anim.borrow_mut();
|
||||||
|
if current_time - s.last_frame_time >= FRAME_DURATION_MS {
|
||||||
|
should_update = true;
|
||||||
|
s.last_frame_time = current_time;
|
||||||
|
}
|
||||||
|
|
||||||
|
if should_update {
|
||||||
|
if s.cursor_active {
|
||||||
|
let cursor_row = s.cursor_row;
|
||||||
|
let cursor_col = s.cursor_col;
|
||||||
|
s.universe.set_alive_block(cursor_row, cursor_col, 2);
|
||||||
|
}
|
||||||
|
s.universe.tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
s.draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
window_for_anim
|
||||||
|
.request_animation_frame(f.borrow().as_ref().unwrap().as_ref().unchecked_ref())
|
||||||
|
.ok();
|
||||||
|
}) as Box<dyn FnMut()>));
|
||||||
|
|
||||||
|
state_rc
|
||||||
|
.borrow()
|
||||||
|
.window
|
||||||
|
.request_animation_frame(g.borrow().as_ref().unwrap().as_ref().unchecked_ref())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
10
cgol/src/utils.rs
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
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();
|
||||||
|
}
|
||||||
45
eslint.config.js
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { FlatCompat } from "@eslint/eslintrc";
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
const compat = new FlatCompat({
|
||||||
|
baseDirectory: import.meta.dirname,
|
||||||
|
});
|
||||||
|
|
||||||
|
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 } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
linterOptions: {
|
||||||
|
reportUnusedDisableDirectives: true
|
||||||
|
},
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
63
flake.nix
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
{
|
||||||
|
description = "CTF Jet development environment (Bun)";
|
||||||
|
|
||||||
|
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:
|
||||||
|
let
|
||||||
|
overlays = [ (import rust-overlay) ];
|
||||||
|
pkgs = import nixpkgs {
|
||||||
|
inherit system overlays;
|
||||||
|
};
|
||||||
|
|
||||||
|
bun = pkgs.bun;
|
||||||
|
|
||||||
|
# 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";
|
||||||
|
};
|
||||||
|
|
||||||
|
packages = {
|
||||||
|
inherit bun prismaEngines;
|
||||||
|
|
||||||
|
default = pkgs.symlinkJoin {
|
||||||
|
name = "ctfjet-dev-bun";
|
||||||
|
paths = [ bun prismaEngines ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
36
install.sh
Executable file
|
|
@ -0,0 +1,36 @@
|
||||||
|
#!/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
|
||||||
|
|
||||||
100
next.config.js
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
60
package.json
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
{
|
||||||
|
"name": "website",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "next build",
|
||||||
|
"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",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/client": "^6.6.0",
|
||||||
|
"@t3-oss/env-nextjs": "^0.12.0",
|
||||||
|
"@tanstack/react-query": "^5.69.0",
|
||||||
|
"@trpc/client": "^11.0.0",
|
||||||
|
"@trpc/react-query": "^11.0.0",
|
||||||
|
"@trpc/server": "^11.0.0",
|
||||||
|
"anser": "^2.3.2",
|
||||||
|
"cgol": "file:./cgol/pkg",
|
||||||
|
"escape-carriage": "^1.3.1",
|
||||||
|
"next": "^15.2.3",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"server-only": "^0.0.1",
|
||||||
|
"superjson": "^2.2.1",
|
||||||
|
"zod": "^3.24.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
|
"@tailwindcss/postcss": "^4.0.15",
|
||||||
|
"@types/node": "^20.14.10",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"eslint": "^9.23.0",
|
||||||
|
"eslint-config-next": "^15.2.3",
|
||||||
|
"postcss": "^8.5.3",
|
||||||
|
"prettier": "^3.5.3",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||||
|
"prisma": "^6.6.0",
|
||||||
|
"raw-loader": "^4.0.2",
|
||||||
|
"tailwindcss": "^4.0.15",
|
||||||
|
"typescript": "^5.8.2",
|
||||||
|
"typescript-eslint": "^8.27.0"
|
||||||
|
},
|
||||||
|
"ct3aMetadata": {
|
||||||
|
"initVersion": "7.40.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
5
postcss.config.js
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
4
prettier.config.js
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
/** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */
|
||||||
|
export default {
|
||||||
|
plugins: ["prettier-plugin-tailwindcss"],
|
||||||
|
};
|
||||||
21
prisma/schema.prisma
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
// 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])
|
||||||
|
}
|
||||||
BIN
public/Web437_IBM_VGA_8x16.woff
Normal file
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
1
public/jet.svg
Normal file
|
After Width: | Height: | Size: 502 KiB |
BIN
public/web-app-manifest-192x192.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
public/web-app-manifest-512x512.png
Normal file
|
After Width: | Height: | Size: 314 KiB |
108
src/app/_components/ansi.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
import Anser, { type AnserJsonEntry } from "anser";
|
||||||
|
import { escapeCarriageReturn } from "escape-carriage";
|
||||||
|
import React 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)]",
|
||||||
|
};
|
||||||
|
|
||||||
|
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)]",
|
||||||
|
};
|
||||||
|
|
||||||
|
const decorationMap: Record<string, string> = {
|
||||||
|
bold: "font-bold",
|
||||||
|
dim: "opacity-50",
|
||||||
|
italic: "italic",
|
||||||
|
hidden: "invisible",
|
||||||
|
strikethrough: "line-through",
|
||||||
|
underline: "underline",
|
||||||
|
blink: "animate-pulse",
|
||||||
|
};
|
||||||
|
|
||||||
|
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 = [];
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Ansi({ className, children = "" }: Props) {
|
||||||
|
const input = escapeCarriageReturn(fixBackspace(children));
|
||||||
|
const bundles = Anser.ansiToJson(input, {
|
||||||
|
json: true,
|
||||||
|
remove_empty: true,
|
||||||
|
use_classes: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<pre className={className ?? ""} style={{ textAlign: "left" }}>
|
||||||
|
<code>
|
||||||
|
{bundles.map((bundle, key) => {
|
||||||
|
const bundleClassName = createClass(bundle);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span key={key} className={bundleClassName ?? undefined}>
|
||||||
|
{bundle.content}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
67
src/app/_components/bordered-box.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { type ReactNode } from "react";
|
||||||
|
|
||||||
|
interface BorderedBoxProps {
|
||||||
|
label?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BorderedBox({
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
}: BorderedBoxProps) {
|
||||||
|
// Generate unique ID for this instance to avoid conflicts
|
||||||
|
const maskId = `borderMask-${Math.random().toString(36).substring(2, 11)}`;
|
||||||
|
|
||||||
|
// Calculate SVG mask values - approximate 1ch = 16px for IBM VGA font
|
||||||
|
const chToPx = 16;
|
||||||
|
const maskX = 4 + chToPx; // 4px + 1ch
|
||||||
|
const maskWidth = label ? label.length * chToPx : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`relative my-[calc(2ch-2px)] px-[calc(1.5ch-0.5px)] py-[1ch] ${className}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 border-2 border-white"
|
||||||
|
style={{
|
||||||
|
maskImage: label ? `url(#${maskId})` : "none",
|
||||||
|
WebkitMaskImage: label ? `url(#${maskId})` : "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{label && (
|
||||||
|
<svg
|
||||||
|
className="pointer-events-none absolute inset-0 h-full w-full"
|
||||||
|
style={{ zIndex: 1 }}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<mask id={maskId}>
|
||||||
|
<rect width="100%" height="100%" fill="white" />
|
||||||
|
<rect
|
||||||
|
x={maskX}
|
||||||
|
y="-8"
|
||||||
|
width={maskWidth}
|
||||||
|
height="16"
|
||||||
|
fill="black"
|
||||||
|
/>
|
||||||
|
</mask>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{label && (
|
||||||
|
<span
|
||||||
|
className="absolute -top-[1ch] bg-transparent text-white"
|
||||||
|
style={{ zIndex: 2 }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative z-10">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
50
src/app/_components/cgol-canvas.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
export function CgolCanvas() {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const initializedRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
|
const initializeWasm = async () => {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error("Failed to initialize CGOL WebAssembly module:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
void initializeWasm();
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
id="canvas"
|
||||||
|
className="fixed top-0 left-0 w-screen h-screen -z-10"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
44
src/app/_components/frosted-box.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
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-[calc(0.5ch-0.5px)] py-[1ch] ${className}`}
|
||||||
|
>
|
||||||
|
{/* Extended frosted glass backdrop with mask */}
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-0 h-[200%] bg-black/60 backdrop-blur-lg"
|
||||||
|
style={{
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
16
src/app/_components/header.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
50
src/app/_components/post.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
src/app/api/trpc/[trpc]/route.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
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 };
|
||||||
BIN
src/app/apple-icon.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
src/app/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
3
src/app/icon0.svg
Normal file
|
After Width: | Height: | Size: 979 KiB |
BIN
src/app/icon1.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
26
src/app/layout.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import "~/styles/globals.css";
|
||||||
|
|
||||||
|
import { type Metadata } 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 default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{ children: React.ReactNode }>) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body>
|
||||||
|
<TRPCReactProvider>{children}</TRPCReactProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
src/app/manifest.json
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"name": "Jet Pham",
|
||||||
|
"short_name": "Jet Pham",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/web-app-manifest-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/web-app-manifest-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"theme_color": "#a80055",
|
||||||
|
"background_color": "#a80055",
|
||||||
|
"display": "standalone"
|
||||||
|
}
|
||||||
|
|
||||||
89
src/app/page.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
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="mt-4 w-full max-w-[66.666667%] min-w-fit px-[calc(1.5ch-0.5px)] md:mt-16">
|
||||||
|
<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-[3ch]">Software Extremist</div>
|
||||||
|
</div>
|
||||||
|
<div className="order-2 flex-shrink-0 px-[1ch] md:order-1">
|
||||||
|
<div className="md:hidden w-full flex justify-center">
|
||||||
|
<div className="w-full max-w-[250px] aspect-square overflow-hidden">
|
||||||
|
<Image
|
||||||
|
src="/jet.svg"
|
||||||
|
alt="Jet"
|
||||||
|
width={250}
|
||||||
|
height={250}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Image
|
||||||
|
src="/jet.svg"
|
||||||
|
alt="Jet"
|
||||||
|
width={175}
|
||||||
|
height={263}
|
||||||
|
className="hidden md:block w-[175px] h-[263px]"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<BorderedBox label="Skills">
|
||||||
|
<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
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</BorderedBox>
|
||||||
|
</FrostedBox>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</HydrateClient>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
src/assets/Jet.txt
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
[40;37m [40;92m▄▄[40;37m [40;92m▄▄[40;37m
|
||||||
|
[40;90m▐[46;92m▓▀░[0m [40;37m [46;92m▓▀ [40;92m▀[40;37m
|
||||||
|
[40;36m▐[46;92m▒[40;36m█▓ [40;37m [40;92m▄▄▓▀[40;36m▓▄▄[40;37m [40;92m▄▄[46;92m▒ [40;36m▓▄▄
|
||||||
|
[40;37m [40;36m▐[46;94m░[40;36m█▓ [40;37m [46;92m▒▀ [40;37m [46;92m░ [0m [40;37m [46;92m░ [40;37m
|
||||||
|
[46;94m▒▄▓[0m [40;36m▐[46;92m░ [40;36m▓▀▀▀▀▀▀ [40;37m [40;34m░[46;94m▒▄░[40;37m
|
||||||
|
[46;94m▓[40;94m█▓ [40;37m [46;94m▓░▄[40;37m [46;94m░▓[40;94m█ [40;37m [46;94m▓[40;94m█[46;94m▒[40;37m
|
||||||
|
[40;94m▐▄██▀[0m [40;37m [40;94m▀▀[46;94m▓[40;94m▄▓▀▀[40;37m [0m [40;37m [40;94m▀[47;94m▓[46;94m▓[40;94m▄[40;37m
|
||||||
|
[0m
|
||||||
44
src/env.js
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
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
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
declare module "*.txt" {
|
||||||
|
const content: string;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "*.utf8ans" {
|
||||||
|
const content: string;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
|
|
||||||
23
src/server/api/root.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
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);
|
||||||
31
src/server/api/routers/post.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
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;
|
||||||
|
}),
|
||||||
|
});
|
||||||
106
src/server/api/trpc.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
16
src/server/db.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
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;
|
||||||
81
src/styles/globals.css
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "IBM VGA";
|
||||||
|
src: url("/Web437_IBM_VGA_8x16.woff") format("woff");
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
: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;
|
||||||
|
|
||||||
|
/* 16-color palette */
|
||||||
|
--black: #000000;
|
||||||
|
--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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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.5rem; /* Increased font size to make characters bigger */
|
||||||
|
white-space: pre;
|
||||||
|
line-height: 1;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden; /* Disable scrolling */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
25
src/trpc/query-client.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
78
src/trpc/react.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
"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}`;
|
||||||
|
}
|
||||||
30
src/trpc/server.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
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
|
||||||
|
);
|
||||||
88
start-database.sh
Executable file
|
|
@ -0,0 +1,88 @@
|
||||||
|
#!/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"
|
||||||
42
tsconfig.json
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
{
|
||||||
|
"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,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"jsx": "preserve",
|
||||||
|
"plugins": [{ "name": "next" }],
|
||||||
|
"incremental": true,
|
||||||
|
|
||||||
|
/* Path Aliases */
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"~/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
"**/*.cjs",
|
||||||
|
"**/*.js",
|
||||||
|
".next/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules", "generated", "cgol/pkg"]
|
||||||
|
}
|
||||||