feat: add project and email service

This commit is contained in:
Jet Pham 2026-03-11 13:00:51 -07:00 committed by Jet
parent 99715f6105
commit f48390b15e
29 changed files with 2631 additions and 63 deletions

View file

@ -0,0 +1,14 @@
export function frostedBox(content: string, extraClasses?: string): string {
return `
<div class="relative px-[2ch] py-[2ch] my-[2ch] w-full max-w-[66.666667%] min-w-fit ${extraClasses ?? ""}">
<div
class="pointer-events-none absolute inset-0 h-[200%]"
style="background-color: rgba(0, 0, 0, 0.75); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); mask-image: linear-gradient(to bottom, black 0% 50%, transparent 50% 100%); -webkit-mask-image: linear-gradient(to bottom, black 0% 50%, transparent 50% 100%);"
aria-hidden="true"
></div>
<div class="absolute inset-0 border-2 border-white" aria-hidden="true"></div>
<div class="relative z-10">
${content}
</div>
</div>`;
}

View file

@ -0,0 +1,10 @@
---
title: Conway's Game of Life
description: WebAssembly implementation of Conway's Game of Life, running as the background of this website.
---
The background animation on this site is a WebAssembly implementation of
Conway's Game of Life, written in Rust and compiled to WASM.
It runs directly in your browser using the HTML5 Canvas API, simulating
cellular automata in real-time.

20
src/global.d.ts vendored
View file

@ -14,3 +14,23 @@ declare module "*.utf8ans?raw" {
const content: string;
export default content;
}
declare module "virtual:projects" {
interface Project {
slug: string;
title: string;
description: string;
html: string;
}
const projects: Project[];
export default projects;
}
declare module "gray-matter" {
interface GrayMatterResult {
data: Record<string, string>;
content: string;
}
function matter(input: string): GrayMatterResult;
export = matter;
}

25
src/lib/api.ts Normal file
View file

@ -0,0 +1,25 @@
export interface Question {
id: number;
question: string;
answer: string;
created_at: string;
answered_at: string;
}
export async function getQuestions(): Promise<Question[]> {
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 submitQuestion(question: string): Promise<void> {
const res = await fetch("/api/questions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ question }),
});
if (!res.ok) {
if (res.status === 429) throw new Error("Too many questions. Please try again later.");
throw new Error("Failed to submit question");
}
}

View file

@ -1,8 +1,17 @@
import "~/styles/globals.css";
import Jet from "~/assets/Jet.txt?ansi";
import init, { start } from "cgol";
import { route, initRouter } from "~/router";
import { homePage } from "~/pages/home";
import { projectsPage } from "~/pages/projects";
import { projectPage } from "~/pages/project";
import { qaPage } from "~/pages/qa";
import { notFoundPage } from "~/pages/not-found";
document.getElementById("ansi-art")!.innerHTML = Jet;
route("/", homePage);
route("/projects", projectsPage);
route("/projects/:slug", projectPage);
route("/qa", qaPage);
route("*", notFoundPage);
try {
await init();
@ -10,3 +19,5 @@ try {
} catch (e) {
console.error("WASM init failed:", e);
}
initRouter();

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

@ -0,0 +1,40 @@
import Jet from "~/assets/Jet.txt?ansi";
import { frostedBox } from "~/components/frosted-box";
export function homePage(outlet: HTMLElement) {
outlet.innerHTML = `
<div class="flex flex-col items-center justify-start px-4">
${frostedBox(`
<div class="flex flex-col items-center justify-center gap-[2ch] md:flex-row">
<div class="order-1 flex flex-col items-center md:order-2">
<h1 class="sr-only">Jet Pham</h1>
<div aria-hidden="true">${Jet}</div>
<p class="mt-[2ch]">Software Extremist</p>
</div>
<div class="order-2 shrink-0 md:order-1">
<img
src="/jet.svg"
alt="A picture of Jet wearing a beanie in purple and blue lighting"
width="250"
height="250"
class="aspect-square w-full max-w-[250px] object-cover md:h-[263px] md:w-[175px] md:max-w-none"
style="background-color: #a80055; color: transparent"
/>
</div>
</div>
<fieldset class="mt-[2ch] border-2 border-white px-[calc(1.5ch-0.5px)] pb-[1ch] pt-0">
<legend class="-mx-[0.5ch] px-[0.5ch] text-white">Contact</legend>
<a href="mailto:jet@extremist.software">jet@extremist.software</a>
</fieldset>
<fieldset class="mt-[2ch] border-2 border-white px-[calc(1.5ch-0.5px)] pb-[1ch] pt-0">
<legend class="-mx-[0.5ch] px-[0.5ch] text-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>`;
}

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

@ -0,0 +1,12 @@
import { frostedBox } from "~/components/frosted-box";
export function notFoundPage(outlet: HTMLElement) {
outlet.innerHTML = `
<div class="flex flex-col items-center justify-start px-4">
${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>`;
}

24
src/pages/project.ts Normal file
View file

@ -0,0 +1,24 @@
import projects from "virtual:projects";
import { frostedBox } from "~/components/frosted-box";
export function projectPage(outlet: HTMLElement, params: Record<string, string>) {
const project = projects.find((p) => p.slug === params.slug);
if (!project) {
outlet.innerHTML = `
<div class="flex flex-col items-center justify-start px-4">
${frostedBox(`
<h1 style="color: var(--light-red);">Project not found</h1>
<p class="mt-[1ch]"><a href="/projects">[BACK TO PROJECTS]</a></p>
`)}
</div>`;
return;
}
outlet.innerHTML = `
<div class="flex flex-col items-center justify-start px-4">
${frostedBox(`
<h1 style="color: var(--yellow);">${project.title}</h1>
<div class="project-content mt-[2ch]">${project.html}</div>
`)}
</div>`;
}

22
src/pages/projects.ts Normal file
View file

@ -0,0 +1,22 @@
import projects from "virtual:projects";
import { frostedBox } from "~/components/frosted-box";
export function projectsPage(outlet: HTMLElement) {
const list = projects
.map(
(p) => `
<li class="mb-[1ch]">
<a href="/projects/${p.slug}" style="color: var(--light-cyan);">${p.title}</a>
<p style="color: var(--light-gray);">${p.description}</p>
</li>`,
)
.join("");
outlet.innerHTML = `
<div class="flex flex-col items-center justify-start px-4">
${frostedBox(`
<h1 style="color: var(--yellow);">Projects</h1>
<ul class="mt-[2ch]">${list}</ul>
`)}
</div>`;
}

87
src/pages/qa.ts Normal file
View file

@ -0,0 +1,87 @@
import { getQuestions, submitQuestion } from "~/lib/api";
import { frostedBox } from "~/components/frosted-box";
function escapeHtml(str: string): string {
const div = document.createElement("div");
div.textContent = str;
return div.innerHTML;
}
export async function qaPage(outlet: HTMLElement) {
outlet.innerHTML = `
<div class="flex flex-col items-center justify-start px-4">
${frostedBox(`
<h1 style="color: var(--yellow);">Q&A</h1>
<form id="qa-form" class="mt-[2ch]">
<fieldset class="border-2 border-white px-[calc(1.5ch-0.5px)] pb-[1ch] pt-0">
<legend class="-mx-[0.5ch] px-[0.5ch] text-white">Ask a Question</legend>
<textarea id="qa-input" maxlength="200" rows="3"
class="qa-textarea"
placeholder="Type your question..."></textarea>
<div class="flex justify-between mt-[0.5ch]">
<span id="char-count" style="color: var(--dark-gray);">0/200</span>
<button type="submit" class="qa-button">[SUBMIT]</button>
</div>
<p id="qa-status" class="mt-[0.5ch]" aria-live="polite"></p>
</fieldset>
</form>
<div id="qa-list" class="mt-[2ch]">Loading...</div>
`)}
</div>`;
const form = document.getElementById("qa-form") as HTMLFormElement;
const input = document.getElementById("qa-input") as HTMLTextAreaElement;
const charCount = document.getElementById("char-count")!;
const status = document.getElementById("qa-status")!;
const list = document.getElementById("qa-list")!;
input.addEventListener("input", () => {
charCount.textContent = `${input.value.length}/200`;
});
form.addEventListener("submit", (e) => {
e.preventDefault();
const question = input.value.trim();
if (!question) return;
status.textContent = "Submitting...";
status.style.color = "var(--light-gray)";
submitQuestion(question)
.then(() => {
input.value = "";
charCount.textContent = "0/200";
status.textContent = "Question submitted! It will appear here once answered.";
status.style.color = "var(--light-green)";
})
.catch((err: unknown) => {
status.textContent = err instanceof Error ? err.message : "Failed to submit question.";
status.style.color = "var(--light-red)";
});
});
try {
const questions = await getQuestions();
if (questions.length === 0) {
list.textContent = "No questions answered yet.";
list.style.color = "var(--dark-gray)";
} else {
list.innerHTML = questions
.map(
(q) => `
<fieldset class="border-2 border-white px-[calc(1.5ch-0.5px)] pb-[1ch] pt-0 mb-[2ch]">
<legend class="-mx-[0.5ch] px-[0.5ch]" style="color: var(--dark-gray);">#${String(q.id)}</legend>
<p style="color: var(--light-cyan);">${escapeHtml(q.question)}</p>
<p class="mt-[1ch]" style="color: var(--light-green);">${escapeHtml(q.answer)}</p>
<p class="mt-[0.5ch]" style="color: var(--dark-gray);">
Asked ${q.created_at} · Answered ${q.answered_at}
</p>
</fieldset>`,
)
.join("");
}
} catch {
list.textContent = "Failed to load questions.";
list.style.color = "var(--light-red)";
}
}

68
src/router.ts Normal file
View file

@ -0,0 +1,68 @@
type PageHandler = (
outlet: HTMLElement,
params: Record<string, string>,
) => void | Promise<void>;
interface Route {
pattern: RegExp;
keys: string[];
handler: PageHandler;
}
const routes: Route[] = [];
let notFoundHandler: PageHandler | null = null;
export function route(path: string, handler: PageHandler) {
if (path === "*") {
notFoundHandler = handler;
return;
}
const keys: string[] = [];
const pattern = path.replace(/:(\w+)/g, (_, key: string) => {
keys.push(key);
return "([^/]+)";
});
routes.push({ pattern: new RegExp(`^${pattern}$`), keys, handler });
}
export function navigate(path: string) {
history.pushState(null, "", path);
void render();
}
async function render() {
const path = location.pathname;
const outlet = document.getElementById("outlet")!;
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);
return;
}
}
outlet.innerHTML = "";
if (notFoundHandler) {
await notFoundHandler(outlet, {});
}
}
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.hasAttribute("download")) {
e.preventDefault();
navigate(anchor.pathname);
}
});
void render();
}

View file

@ -82,5 +82,70 @@
a:hover {
color: var(--blue);
}
/* Form inputs */
.qa-textarea {
width: 100%;
background-color: var(--black);
border: 1px solid var(--white);
color: var(--light-gray);
padding: 1ch;
resize: none;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
.qa-button {
border: 1px solid var(--white);
padding: 0 1ch;
color: var(--yellow);
background: transparent;
font-family: inherit;
font-size: inherit;
cursor: pointer;
}
.qa-button:hover {
background-color: var(--yellow);
color: var(--black);
}
/* Project markdown content */
.project-content h2 {
color: var(--light-cyan);
margin-top: 2ch;
margin-bottom: 1ch;
}
.project-content h3 {
color: var(--light-green);
margin-top: 1.5ch;
margin-bottom: 0.5ch;
}
.project-content p {
margin-bottom: 1ch;
}
.project-content ul,
.project-content ol {
margin-left: 2ch;
margin-bottom: 1ch;
}
.project-content code {
color: var(--yellow);
}
.project-content pre {
border: 1px solid var(--dark-gray);
padding: 1ch;
overflow-x: auto;
margin-bottom: 1ch;
}
.project-content a {
color: var(--light-blue);
}