feat: add project and email service
This commit is contained in:
parent
99715f6105
commit
f48390b15e
29 changed files with 2631 additions and 63 deletions
14
src/components/frosted-box.ts
Normal file
14
src/components/frosted-box.ts
Normal 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>`;
|
||||
}
|
||||
10
src/content/projects/cgol.md
Normal file
10
src/content/projects/cgol.md
Normal 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
20
src/global.d.ts
vendored
|
|
@ -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
25
src/lib/api.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
15
src/main.ts
15
src/main.ts
|
|
@ -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
40
src/pages/home.ts
Normal 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
12
src/pages/not-found.ts
Normal 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
24
src/pages/project.ts
Normal 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
22
src/pages/projects.ts
Normal 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
87
src/pages/qa.ts
Normal 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
68
src/router.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue