Compare commits

...

14 Commits

Author SHA1 Message Date
228240ec2d added all for contact form, now test
Some checks failed
Deploy Jody's App / build-and-deploy (push) Failing after 1m16s
2026-02-18 21:50:11 -06:00
87f0443b31 feat: testing contact form features 2026-02-18 21:34:16 -06:00
7d9c0014ed update: added link to skymoney and updated to beta status on project container
All checks were successful
Deploy Jody's App / build-and-deploy (push) Successful in 18s
2026-02-01 00:47:39 -06:00
3caf1e30b0 updated resume, added resume data api folder for easy updates
All checks were successful
Deploy Jody's App / build-and-deploy (push) Successful in 17s
2026-01-27 00:16:01 -06:00
b4ef9fd2e0 fixed footer alignment
All checks were successful
Deploy Jody's App / build-and-deploy (push) Successful in 19s
2026-01-21 22:38:59 -06:00
66f63523e2 fixed animation for mobile nav, and theme toggle
All checks were successful
Deploy Jody's App / build-and-deploy (push) Successful in 15s
2026-01-07 21:18:25 -06:00
9a84d4b78c fixed animation for mobile nav
All checks were successful
Deploy Jody's App / build-and-deploy (push) Successful in 15s
2026-01-07 21:11:42 -06:00
f559a28b74 added favicon and header title; 2026-01-07 21:05:09 -06:00
4e82497640 added deploy yml
All checks were successful
Deploy Jody's App / build-and-deploy (push) Successful in 15s
2026-01-07 21:02:20 -06:00
Ben Mosley
39507d4bd8 hooking up Jody with the good shit
All checks were successful
Deploy Jody's App / build-and-deploy (push) Successful in 17s
2026-01-07 20:26:39 -06:00
Ben Mosley
0fb0877a92 hooking up jody with the good shit
Some checks failed
Deploy Jody's App / build-and-deploy (push) Failing after 16s
2026-01-07 20:25:21 -06:00
Ben Mosley
6f7efed5e5 Hooking up Jody with the good shit
Some checks failed
Deploy Jody's App / build-and-deploy (push) Failing after 18s
2026-01-07 20:18:47 -06:00
594aa96dc7 Update README.md 2026-01-07 05:17:26 +00:00
d5a2914915 Delete variable-categories.ts 2026-01-07 05:05:08 +00:00
30 changed files with 2842 additions and 176 deletions

View File

@@ -0,0 +1,51 @@
name: Deploy Jody's App
on:
push:
branches:
- main
jobs:
build-and-deploy:
runs-on: vps-host
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Install Frontend Dependencies
run: npm ci
- name: Build Frontend
run: npm run build
- name: Install Contact API Dependencies
run: npm ci --prefix contact-api
- name: Build Contact API
run: npm run build --prefix contact-api
- name: Sync Frontend Files
run: |
mkdir -p /var/www/jody/dist
rm -rf /var/www/jody/dist/*
cp -r dist/* /var/www/jody/dist/
- name: Sync Contact API Runtime Files
run: |
mkdir -p /var/www/jody/contact-api
rm -rf /var/www/jody/contact-api/dist
cp -r contact-api/dist /var/www/jody/contact-api/
cp contact-api/package.json /var/www/jody/contact-api/
cp contact-api/package-lock.json /var/www/jody/contact-api/
- name: Install Contact API Production Dependencies
run: |
cd /var/www/jody/contact-api
npm ci --omit=dev
- name: Restart Contact API
run: |
systemctl restart jody-contact-api
systemctl is-active --quiet jody-contact-api
- name: Health Check Contact API
run: curl --fail --silent http://127.0.0.1:8787/health

3
.gitignore vendored
View File

@@ -7,6 +7,7 @@ pnpm-debug.log*
# Build output
dist/
build/
contact-api/dist/
# Vite cache
.vite/
@@ -20,6 +21,7 @@ build/
# Environment variables
.env
.env.*.local
contact-api/.env
# OS generated
.DS_Store
@@ -30,3 +32,4 @@ Thumbs.db
# yarn.lock
# package-lock.json
# pnpm-lock.yamlw

View File

@@ -47,25 +47,9 @@ src/
These themes are applied via html[data-theme="x"] and are used across the site for all gradients, tints, accents, etc.
## DEV NOTES as of 10/29/2025
- All social links are dummy values as of now.
- Section layout is controlled via <Section id="...."></Section> wrappers.
- Images are outdated and will be replaced.
- UI is mobile-oriented, but device friendly.
## TODO
- Add links to projects within cards
- Change out experience tab for resume/skills
- Add animations
- more ways to contact
- Deploy site via personal service
## View Progress
## To run on local client:
```bash
npm i
@@ -75,9 +59,33 @@ npm run dev
```
## Contact Form Setup
### Frontend env
Create `.env` in the project root:
```bash
VITE_CONTACT_API_URL=/api/contact
VITE_TURNSTILE_SITE_KEY=your_turnstile_site_key
```
### Backend API
The contact backend lives in `contact-api/`.
```bash
cd contact-api
npm i
npm run dev
```
Create `contact-api/.env` from `contact-api/.env.example` and set your real Turnstile + SMTP credentials.
### VPS service files
- systemd unit template: `ops/jody-contact-api.service`
- Nginx reverse proxy snippet: `ops/nginx-contact-api.conf`
## Author
### Jody Holt
### Frontend Developer • Passion Pioneer
#### [GitHub](https://github.com/Ricearoni1245) • [LinkedIn](https://www.linkedin.com/in/jody-holt-9b19b0256) • [Email](mailto:jholt1008@gmail.com)
#### [GitHub](https://github.com/Ricearoni1245) • [LinkedIn](https://www.linkedin.com/in/jody-holt-9b19b0256)

BIN
contact-api-release.tgz Normal file

Binary file not shown.

1755
contact-api/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
contact-api/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "contact-api",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js"
},
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^4.21.2",
"express-rate-limit": "^7.5.1",
"helmet": "^8.1.0",
"nodemailer": "^7.0.10",
"pino-http": "^11.0.0",
"zod": "^3.24.2"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/node": "^24.6.0",
"@types/nodemailer": "^7.0.2",
"tsx": "^4.20.5",
"typescript": "~5.9.3"
}
}

41
contact-api/src/config.ts Normal file
View File

@@ -0,0 +1,41 @@
import dotenv from "dotenv";
import { z } from "zod";
dotenv.config();
const boolFromEnv = z
.enum(["true", "false"])
.transform((value) => value === "true");
const envSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
PORT: z.coerce.number().int().positive().default(8787),
CONTACT_ALLOWED_ORIGIN: z.string().url(),
TURNSTILE_SECRET_KEY: z.string().min(1),
TURNSTILE_EXPECTED_HOSTNAME: z.string().min(1),
TURNSTILE_EXPECTED_ACTION: z.string().min(1).default("contact_form"),
SMTP_HOST: z.string().min(1),
SMTP_PORT: z.coerce.number().int().positive().default(587),
SMTP_SECURE: boolFromEnv.default("false"),
SMTP_REQUIRE_TLS: boolFromEnv.default("true"),
SMTP_USER: z.string().min(1),
SMTP_PASS: z.string().min(1),
MAIL_FROM_NAME: z.string().min(1).default("Portfolio Contact"),
MAIL_FROM_ADDRESS: z.string().email(),
MAIL_TO_ADDRESS: z.string().email(),
MAIL_SUBJECT_PREFIX: z.string().min(1).default("[Portfolio Contact]"),
RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(600000),
RATE_LIMIT_MAX: z.coerce.number().int().positive().default(5),
HONEYPOT_FIELD: z.string().min(1).default("website"),
MIN_SUBMIT_TIME_MS: z.coerce.number().int().nonnegative().default(3000),
});
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error("Invalid contact-api environment configuration:");
console.error(parsed.error.flatten().fieldErrors);
process.exit(1);
}
export const config = parsed.data;

138
contact-api/src/index.ts Normal file
View File

@@ -0,0 +1,138 @@
import cors from "cors";
import express, { type NextFunction, type Request, type Response } from "express";
import rateLimit from "express-rate-limit";
import helmet from "helmet";
import { pinoHttp } from "pino-http";
import { config } from "./config.js";
import { sendContactEmail, verifyMailerConnection } from "./mailer.js";
import { verifyTurnstileToken } from "./turnstile.js";
import { contactRequestSchema } from "./validation.js";
type ApiErrorResponse = {
ok: false;
error: string;
};
const app = express();
app.set("trust proxy", 1);
app.use(
pinoHttp({
level: config.NODE_ENV === "production" ? "info" : "debug",
redact: {
paths: ["req.headers.authorization"],
remove: true,
},
}),
);
app.use(helmet());
app.use(
cors({
origin(origin, callback) {
if (!origin || origin === config.CONTACT_ALLOWED_ORIGIN) {
callback(null, true);
return;
}
callback(new Error("Origin not allowed"));
},
methods: ["GET", "POST", "OPTIONS"],
allowedHeaders: ["Content-Type"],
}),
);
app.use(express.json({ limit: "16kb" }));
app.use((err: Error, _req: Request, res: Response<ApiErrorResponse>, next: NextFunction) => {
if (err.message === "Origin not allowed") {
res.status(403).json({ ok: false, error: "Origin not allowed." });
return;
}
next(err);
});
const contactLimiter = rateLimit({
windowMs: config.RATE_LIMIT_WINDOW_MS,
max: config.RATE_LIMIT_MAX,
standardHeaders: true,
legacyHeaders: false,
message: {
ok: false,
error: "Too many requests. Please try again later.",
},
});
app.get("/health", (_req, res) => {
res.status(200).json({ ok: true });
});
app.post("/contact", contactLimiter, async (req: Request, res: Response) => {
const body = (req.body ?? {}) as Record<string, unknown>;
const honeypotRaw = body[config.HONEYPOT_FIELD];
if (typeof honeypotRaw === "string" && honeypotRaw.trim().length > 0) {
req.log.warn({ event: "contact_honeypot_triggered" }, "Contact form blocked by honeypot field");
res.status(400).json({ ok: false, error: "Invalid submission." });
return;
}
const startedAt = Number(body.startedAt);
if (!Number.isFinite(startedAt) || Date.now() - startedAt < config.MIN_SUBMIT_TIME_MS) {
req.log.warn({ event: "contact_submit_too_fast" }, "Contact form rejected by min submit time");
res.status(400).json({ ok: false, error: "Please wait a moment and try again." });
return;
}
const parsed = contactRequestSchema.safeParse(body);
if (!parsed.success) {
req.log.warn({ event: "contact_validation_failed", issues: parsed.error.issues }, "Invalid contact payload");
res.status(400).json({ ok: false, error: "Please review your form entries and try again." });
return;
}
try {
const turnstileResult = await verifyTurnstileToken(parsed.data.turnstileToken, req.ip);
if (!turnstileResult.ok) {
req.log.warn(
{ event: "turnstile_failed", reason: turnstileResult.reason },
"Turnstile verification failed",
);
res.status(403).json({ ok: false, error: "Human verification failed. Please try again." });
return;
}
} catch (error) {
req.log.error({ err: error }, "Turnstile verification request failed");
res.status(502).json({ ok: false, error: "Unable to verify your request right now." });
return;
}
try {
await sendContactEmail(parsed.data, req.ip ?? "");
req.log.info({ event: "contact_sent" }, "Contact email sent");
res.status(200).json({ ok: true, message: "Message sent." });
} catch (error) {
req.log.error({ err: error }, "Failed to send contact email");
res.status(502).json({ ok: false, error: "Unable to send message. Please try again soon." });
}
});
app.use((err: Error, req: Request, res: Response<ApiErrorResponse>, _next: NextFunction) => {
void _next;
req.log.error({ err }, "Unhandled contact-api error");
res.status(500).json({ ok: false, error: "Internal server error." });
});
const startServer = async (): Promise<void> => {
await verifyMailerConnection();
app.listen(config.PORT, () => {
console.log(`contact-api listening on port ${config.PORT}`);
});
};
startServer().catch((error) => {
console.error("Failed to start contact-api", error);
process.exit(1);
});

65
contact-api/src/mailer.ts Normal file
View File

@@ -0,0 +1,65 @@
import nodemailer from "nodemailer";
import { config } from "./config.js";
import type { ContactRequest } from "./validation.js";
const transporter = nodemailer.createTransport({
host: config.SMTP_HOST,
port: config.SMTP_PORT,
secure: config.SMTP_SECURE,
requireTLS: config.SMTP_REQUIRE_TLS,
auth: {
user: config.SMTP_USER,
pass: config.SMTP_PASS,
},
tls: {
rejectUnauthorized: true,
},
});
const escapeHtml = (value: string): string =>
value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
const newlineToBreaks = (value: string): string =>
escapeHtml(value).replace(/\n/g, "<br>");
export async function verifyMailerConnection(): Promise<void> {
await transporter.verify();
}
export async function sendContactEmail(payload: ContactRequest, sourceIp: string): Promise<void> {
const receivedAt = new Date().toISOString();
const subject = `${config.MAIL_SUBJECT_PREFIX} ${payload.name}`;
const textBody = [
`Name: ${payload.name}`,
`Email: ${payload.email}`,
`Source IP: ${sourceIp || "unknown"}`,
`Received At: ${receivedAt}`,
"",
"Message:",
payload.message,
].join("\n");
const htmlBody = `
<h2>Portfolio Contact Submission</h2>
<p><strong>Name:</strong> ${escapeHtml(payload.name)}</p>
<p><strong>Email:</strong> ${escapeHtml(payload.email)}</p>
<p><strong>Source IP:</strong> ${escapeHtml(sourceIp || "unknown")}</p>
<p><strong>Received At:</strong> ${escapeHtml(receivedAt)}</p>
<p><strong>Message:</strong><br>${newlineToBreaks(payload.message)}</p>
`;
await transporter.sendMail({
from: `"${config.MAIL_FROM_NAME}" <${config.MAIL_FROM_ADDRESS}>`,
to: config.MAIL_TO_ADDRESS,
replyTo: payload.email,
subject,
text: textBody,
html: htmlBody,
});
}

View File

@@ -0,0 +1,48 @@
import { config } from "./config.js";
type TurnstileVerifyResponse = {
success: boolean;
hostname?: string;
action?: string;
"error-codes"?: string[];
};
export async function verifyTurnstileToken(
token: string,
remoteIp?: string,
): Promise<{ ok: boolean; reason?: string }> {
const body = new URLSearchParams({
secret: config.TURNSTILE_SECRET_KEY,
response: token,
});
if (remoteIp) {
body.append("remoteip", remoteIp);
}
const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
body,
});
if (!response.ok) {
return { ok: false, reason: `turnstile_http_${response.status}` };
}
const result = (await response.json()) as TurnstileVerifyResponse;
if (!result.success) {
const codes = result["error-codes"]?.join(",") ?? "verification_failed";
return { ok: false, reason: codes };
}
if (result.hostname !== config.TURNSTILE_EXPECTED_HOSTNAME) {
return { ok: false, reason: "hostname_mismatch" };
}
if (result.action !== config.TURNSTILE_EXPECTED_ACTION) {
return { ok: false, reason: "action_mismatch" };
}
return { ok: true };
}

View File

@@ -0,0 +1,12 @@
import { z } from "zod";
export const contactRequestSchema = z.object({
name: z.string().trim().min(2).max(80),
email: z.string().trim().email().max(320),
message: z.string().trim().min(20).max(2000),
website: z.string().optional().default(""),
startedAt: z.coerce.number().int().positive(),
turnstileToken: z.string().trim().min(1),
});
export type ContactRequest = z.infer<typeof contactRequestSchema>;

18
contact-api/tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"skipLibCheck": true,
"types": ["node"],
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"resolveJsonModule": true
},
"include": ["src/**/*.ts"]
}

View File

@@ -8,6 +8,11 @@
</head>
<body>
<div id="root"></div>
<script
src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"
async
defer
></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,18 @@
[Unit]
Description=Jody Portfolio Contact API
After=network.target
[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/var/www/jody/contact-api
EnvironmentFile=/etc/jody/contact-api.env
ExecStart=/usr/bin/node /var/www/jody/contact-api/dist/index.js
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,9 @@
# Place inside your HTTPS server block for the portfolio domain.
location /api/ {
proxy_pass http://127.0.0.1:8787/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}

View File

@@ -6,6 +6,8 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"build:api": "npm run build --prefix contact-api",
"build:all": "npm run build && npm run build:api",
"lint": "eslint .",
"preview": "vite preview"
},

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Navbar } from "./components/Navbar";
import { Section } from "./components/Section";
import { Hero } from "./components/Hero";
@@ -7,9 +7,10 @@ import { Projects } from "./components/Projects";
import { Resume } from "./components/Resume";
import { Footer } from "./components/Footer";
import { AboutMe } from "./components/AboutMe";
import { Contact } from "./components/Contact";
export default function App() {
const sections = useMemo(() => ["home", "about", "projects", "experience"], []);
const sections = useMemo(() => ["home", "about", "projects", "experience", "contact"], []);
const refs = useRef<Record<string, HTMLElement | null>>({});
const [active, setActive] = useState<string>(sections[0]);
@@ -55,6 +56,8 @@ return (
<Section id="projects"><Projects /></Section>
<GradientBand />
<Section id="experience"><Resume /></Section>
<GradientBand />
<Section id="contact"><Contact /></Section>
</main>

View File

@@ -1,4 +1,3 @@
import React from "react";
import aboutImg from "../assets/img/about-img.png";
export function AboutMe() {

342
src/components/Contact.tsx Normal file
View File

@@ -0,0 +1,342 @@
import { useEffect, useRef, useState, type ChangeEvent, type FormEvent } from "react";
type FormState = {
name: string;
email: string;
message: string;
website: string;
};
type ToastTone = "info" | "success" | "error";
type ToastState = {
id: number;
tone: ToastTone;
message: string;
};
type ContactApiResponse = {
ok: boolean;
message?: string;
error?: string;
};
const TURNSTILE_ACTION = "contact_form";
const HONEYPOT_FIELD = "website";
const API_URL = import.meta.env.VITE_CONTACT_API_URL || "/api/contact";
const TURNSTILE_SITE_KEY = import.meta.env.VITE_TURNSTILE_SITE_KEY;
const initialForm: FormState = {
name: "",
email: "",
message: "",
website: "",
};
const validateForm = (form: FormState): string | null => {
const name = form.name.trim();
const email = form.email.trim();
const message = form.message.trim();
if (name.length < 2 || name.length > 80) {
return "Please enter a name between 2 and 80 characters.";
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return "Please enter a valid email address.";
}
if (message.length < 20 || message.length > 2000) {
return "Your message should be between 20 and 2000 characters.";
}
return null;
};
const toastToneClasses: Record<ToastTone, string> = {
info: "border-primary/60",
success: "border-primary/70",
error: "border-contrast/80",
};
export function Contact() {
const [form, setForm] = useState<FormState>(initialForm);
const [token, setToken] = useState("");
const [submitting, setSubmitting] = useState(false);
const [toast, setToast] = useState<ToastState | null>(null);
const [turnstileReady, setTurnstileReady] = useState(false);
const startedAtRef = useRef<number>(Date.now());
const turnstileContainerRef = useRef<HTMLDivElement | null>(null);
const turnstileWidgetIdRef = useRef<string | number | null>(null);
const toastTimerRef = useRef<number | null>(null);
const showToast = (tone: ToastTone, message: string): void => {
setToast({ id: Date.now(), tone, message });
};
const resetTurnstile = (): void => {
setToken("");
if (window.turnstile && turnstileWidgetIdRef.current !== null) {
window.turnstile.reset(turnstileWidgetIdRef.current);
}
};
useEffect(() => {
if (!TURNSTILE_SITE_KEY || !turnstileContainerRef.current) {
return;
}
let cancelled = false;
const renderTurnstile = (): void => {
if (cancelled || !turnstileContainerRef.current || turnstileWidgetIdRef.current !== null) {
return;
}
if (!window.turnstile) {
window.setTimeout(renderTurnstile, 120);
return;
}
const widgetId = window.turnstile.render(turnstileContainerRef.current, {
sitekey: TURNSTILE_SITE_KEY,
action: TURNSTILE_ACTION,
callback: (value: string) => {
setToken(value);
},
"expired-callback": () => {
setToken("");
},
"error-callback": () => {
setToken("");
},
});
turnstileWidgetIdRef.current = widgetId;
setTurnstileReady(true);
};
renderTurnstile();
return () => {
cancelled = true;
if (window.turnstile && turnstileWidgetIdRef.current !== null) {
window.turnstile.remove(turnstileWidgetIdRef.current);
turnstileWidgetIdRef.current = null;
}
};
}, []);
useEffect(() => {
if (!toast) {
return;
}
if (toastTimerRef.current !== null) {
window.clearTimeout(toastTimerRef.current);
}
toastTimerRef.current = window.setTimeout(() => {
setToast(null);
toastTimerRef.current = null;
}, 5000);
return () => {
if (toastTimerRef.current !== null) {
window.clearTimeout(toastTimerRef.current);
}
};
}, [toast]);
const handleChange = (
event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
): void => {
const { name, value } = event.currentTarget;
setForm((previous) => ({ ...previous, [name]: value }));
};
const handleSubmit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault();
if (submitting) {
return;
}
if (!TURNSTILE_SITE_KEY) {
showToast("error", "Turnstile is not configured yet.");
return;
}
const validationError = validateForm(form);
if (validationError) {
showToast("error", validationError);
return;
}
if (!token) {
showToast("error", "Please complete human verification before submitting.");
return;
}
setSubmitting(true);
showToast("info", "Sending your message...");
const payload = {
name: form.name.trim(),
email: form.email.trim(),
message: form.message.trim(),
[HONEYPOT_FIELD]: form.website,
startedAt: startedAtRef.current,
turnstileToken: token,
};
try {
const response = await fetch(API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
let responseBody: ContactApiResponse | null = null;
try {
responseBody = (await response.json()) as ContactApiResponse;
} catch {
responseBody = null;
}
if (!response.ok || !responseBody?.ok) {
throw new Error(responseBody?.error || "Unable to send message right now.");
}
setForm(initialForm);
startedAtRef.current = Date.now();
resetTurnstile();
showToast("success", responseBody.message || "Message sent.");
} catch (error) {
const message = error instanceof Error ? error.message : "Unable to send message right now.";
showToast("error", message);
resetTurnstile();
} finally {
setSubmitting(false);
}
};
return (
<section className="mx-auto max-w-5xl px-4 py-16 md:py-24 anim-fade-in">
<div className="mb-8 text-center">
<h2 className="font-title text-3xl md:text-4xl font-extrabold tracking-wide text-text">
Contact
</h2>
<p className="mt-2 text-text/75 font-main text-sm md:text-base">
Send me a message and I&apos;ll reply from my personal inbox.
</p>
</div>
<div className="mx-auto max-w-3xl rounded-2xl border border-secondary bg-secondary/20 p-5 md:p-8 backdrop-blur">
<form onSubmit={handleSubmit} noValidate className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text/90">Name</span>
<input
type="text"
name="name"
value={form.name}
onChange={handleChange}
maxLength={80}
required
className="w-full rounded-lg border border-secondary bg-bg/70 px-4 py-3 text-text placeholder:text-text/50 focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/40 anim-base"
placeholder="Your name"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text/90">Email</span>
<input
type="email"
name="email"
value={form.email}
onChange={handleChange}
maxLength={320}
required
className="w-full rounded-lg border border-secondary bg-bg/70 px-4 py-3 text-text placeholder:text-text/50 focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/40 anim-base"
placeholder="you@domain.com"
/>
</label>
</div>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text/90">Message</span>
<textarea
name="message"
value={form.message}
onChange={handleChange}
minLength={20}
maxLength={2000}
required
className="min-h-[160px] w-full rounded-lg border border-secondary bg-bg/70 px-4 py-3 text-text placeholder:text-text/50 focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/40 anim-base"
placeholder="How can I help?"
/>
</label>
<label className="pointer-events-none absolute -left-[9999px] top-auto h-px w-px overflow-hidden" aria-hidden>
Website
<input
type="text"
name={HONEYPOT_FIELD}
value={form.website}
onChange={handleChange}
tabIndex={-1}
autoComplete="off"
/>
</label>
<div className="space-y-2">
<div ref={turnstileContainerRef} className="min-h-[66px]" />
{!TURNSTILE_SITE_KEY && (
<p className="text-xs text-contrast">Set `VITE_TURNSTILE_SITE_KEY` to enable submissions.</p>
)}
{TURNSTILE_SITE_KEY && !turnstileReady && (
<p className="text-xs text-text/65">Loading human verification...</p>
)}
</div>
<div className="flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-text/65">Protected by Turnstile, rate limits, and server-side validation.</p>
<button
type="submit"
disabled={submitting}
className="rounded-lg border border-primary bg-primary/15 px-5 py-2.5 text-sm font-semibold text-text hover:bg-primary/25 disabled:cursor-not-allowed disabled:opacity-60 anim-base"
>
{submitting ? "Sending..." : "Send Message"}
</button>
</div>
</form>
</div>
{toast && (
<div className="fixed bottom-5 right-5 z-[80] max-w-sm">
<div
className={`rounded-xl border bg-secondary/95 px-4 py-3 text-sm text-text shadow-xl backdrop-blur ${toastToneClasses[toast.tone]} anim-pop-in`}
>
<div className="flex items-start justify-between gap-3">
<p className="font-main leading-snug">{toast.message}</p>
<button
type="button"
onClick={() => setToast(null)}
className="rounded px-2 text-text/70 hover:text-primary anim-base"
aria-label="Dismiss notification"
>
×
</button>
</div>
</div>
</div>
)}
</section>
);
}

View File

@@ -1,16 +1,11 @@
import React from "react";
import githubIcon from "../assets/img/github-icon.png";
import linkedInIcon from "../assets/img/linkedin-icon.png";
import emailIcon from "../assets/img/email-icon.png";
import facebookIcon from "../assets/img/facebook-icon.png";
import phoneIcon from "../assets/img/phone-icon.png";
const defaultSocials = [
{ label: "GitHub", href: "https://github.com/Ricearoni1245", icon: githubIcon },
{ label: "LinkedIn", href: "https://www.linkedin.com/in/jody-holt-9b19b0256", icon: linkedInIcon },
{ label: "Facebook", href: "https://www.facebook.com/jody.holt.7161/", icon: facebookIcon },
{ label: "Email", href: "mailto:jholt1008@gmail.com", icon: emailIcon },
{ label: "Phone", href: "tel:8066542813", icon: phoneIcon },
];
type Social = { label: string; href: string; icon?: string };
@@ -26,7 +21,7 @@ export function Footer({
}) {
return (
<footer className="border-t border-secondary bg-bg px-4 py-10">
<div className="mx-auto flex max-w-7xl flex-col items-center justify-between gap-6 md:flex-row">
<div className="mx-auto max-w-7xl flex flex-col gap-6 md:grid md:grid-cols-3 md:items-end">
<div className="text-center md:text-left">
<div className="text-xl md:text-2xl font-extrabold font-name tracking-wide text-text">
Jody Holt
@@ -36,7 +31,7 @@ export function Footer({
</div>
</div>
<nav className="flex items-center gap-5">
<nav className="flex items-center gap-5 md:justify-center">
<button
className="text-text hover:text-primary anim-base"
onClick={() => document.getElementById("home")?.scrollIntoView({ behavior: "smooth" })}
@@ -55,15 +50,21 @@ export function Footer({
>
Experience
</button>
<button
className="text-text hover:text-primary anim-base"
onClick={() => document.getElementById("contact")?.scrollIntoView({ behavior: "smooth" })}
>
Contact
</button>
</nav>
<div className="flex items-center gap-4 text-text">
<div className="flex items-center gap-4 text-text md:justify-end">
{socials.map((s) => (
<a
key={s.label}
href={s.href}
target={s.href.startsWith("mailto:") || s.href.startsWith("tel:") ? undefined : "_blank"}
rel={s.href.startsWith("mailto:") || s.href.startsWith("tel:") ? undefined : "noopener noreferrer"}
target="_blank"
rel="noopener noreferrer"
aria-label={s.label}
className="inline-flex h-10 w-10 items-center justify-center rounded-lg border border-secondary hover:border-primary hover:text-primary anim-base"
title={s.label}

View File

@@ -1,19 +1,14 @@
import React from "react";
import profileImage from "../assets/img/Jody.png";
import jodyMobile from "../assets/img/Jody-mobile.png";
import { useTheme } from "../hooks/useTheme";
import githubIcon from "../assets/img/github-icon.png";
import linkedInIcon from "../assets/img/linkedin-icon.png";
import emailIcon from "../assets/img/email-icon.png";
import facebookIcon from "../assets/img/facebook-icon.png";
import phoneIcon from "../assets/img/phone-icon.png";
const socialLinks = [
{ label: "GitHub", href: "https://github.com/Ricearoni1245", icon: githubIcon },
{ label: "LinkedIn", href: "https://www.linkedin.com/in/jody-holt-9b19b0256", icon: linkedInIcon },
{ label: "Facebook", href: "https://www.facebook.com/jody.holt.7161/", icon: facebookIcon },
{ label: "Email", href: "mailto:jholt1008@gmail.com", icon: emailIcon },
{ label: "Phone", href: "tel:8066542813", icon: phoneIcon },
];
export function Hero() {
const { theme } = useTheme(); // "a" | "b" | "c" | "d" | "e"
@@ -57,8 +52,8 @@ export function Hero() {
<a
key={a.label}
href={a.href}
target={a.href.startsWith("mailto:") || a.href.startsWith("tel:") ? undefined : "_blank"}
rel={a.href.startsWith("mailto:") || a.href.startsWith("tel:") ? undefined : "noopener noreferrer"}
target="_blank"
rel="noopener noreferrer"
aria-label={a.label}
className="inline-flex h-12 w-12 items-center justify-center rounded-lg
border border-secondary/70 bg-secondary/20 text-text anim-base icon-hover
@@ -127,8 +122,8 @@ export function Hero() {
<a
key={a.label}
href={a.href}
target={a.href.startsWith("mailto:") || a.href.startsWith("tel:") ? undefined : "_blank"}
rel={a.href.startsWith("mailto:") || a.href.startsWith("tel:") ? undefined : "noopener noreferrer"}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center rounded-xl border
border-secondary/70 bg-secondary/20 text-text transition h-10 w-10
sm:h-12 sm:w-12 md:h-14 md:w-14 lg:h-16 lg:w-16 hover:border-primary

View File

@@ -1,4 +1,4 @@
import React, { useState } from "react";
import { useState } from "react";
import { ThemeToggle } from "./ThemeToggle";
export function Navbar({ onNav }: { onNav: (id: string) => void }) {
@@ -8,6 +8,7 @@ export function Navbar({ onNav }: { onNav: (id: string) => void }) {
{ id: "about", label: "About" },
{ id: "projects", label: "Projects" },
{ id: "experience", label: "Resume" },
{ id: "contact", label: "Contact" },
];
const handleNav = (id: string) => {
@@ -71,8 +72,10 @@ export function Navbar({ onNav }: { onNav: (id: string) => void }) {
</div>
<div
className={`md:hidden transition-[max-height] duration-300 ${
open ? "max-h-96 overflow-visible" : "max-h-0 overflow-hidden"
className={`md:hidden transition-[max-height,opacity,transform] duration-300 ease-out ${
open
? "max-h-96 overflow-visible opacity-100 translate-y-0"
: "max-h-0 overflow-hidden opacity-0 -translate-y-2 pointer-events-none"
}`}
>
<div className="space-y-2 border-t border-secondary bg-bg px-4 py-3">

View File

@@ -1,5 +1,4 @@
// =====================================
import React from "react";
export function Placeholder({ title }: { title: string }) {

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from "react";
import { useState, useEffect } from "react";
import skymoneycover from "../assets/img/Skymoney-cover-img.jpg";
import skymoneycoverMobile from "../assets/img/skymoney-mobile-cover-img.jpg";
import millercover from "../assets/img/500nmain-cover-img.jpg";
@@ -18,13 +18,15 @@ type Project = {
videoMobile: string;
techStack: string[];
liveUrl?: string;
beta?: boolean;
note?: string;
comingSoon?: boolean;
};
const projects: Project[] = [
{
id: "skymoney",
title: "Skymoney",
title: "SkyMoney",
description:
"A budgeting app that simulates your bank account to ensure financial discipline.",
coverImage: skymoneycover,
@@ -32,7 +34,9 @@ const projects: Project[] = [
video: skymoneyvideo,
videoMobile: skymoneyvideoMobile,
techStack: ["React", "TypeScript", "Node.js", "PostgreSQL"],
comingSoon: true,
liveUrl: "https://skymoneybudget.com",
beta: true,
note: "Contact Jody for beta access.",
},
{
id: "miller-building",
@@ -154,10 +158,10 @@ function ProjectCard({
</svg>
</div>
</button>
{/* Coming Soon Badge */}
{project.comingSoon && (
{/* Beta Badge */}
{project.beta && (
<div className="absolute top-3 right-3 px-3 py-1 rounded-full bg-primary text-white text-xs font-bold uppercase tracking-wider">
Coming Soon
Beta
</div>
)}
</div>
@@ -187,10 +191,10 @@ function ProjectCard({
</svg>
</div>
</button>
{/* Coming Soon Badge */}
{project.comingSoon && (
{/* Beta Badge */}
{project.beta && (
<div className="absolute top-3 right-3 px-3 py-1 rounded-full bg-primary text-white text-xs font-bold uppercase tracking-wider">
Coming Soon
Beta
</div>
)}
</div>
@@ -201,6 +205,9 @@ function ProjectCard({
<p className="text-text/70 text-sm mb-4 line-clamp-2">
{project.description}
</p>
{project.note && (
<p className="text-text/60 text-xs mb-4">{project.note}</p>
)}
{/* Tech Stack */}
<div className="flex flex-wrap gap-2 mb-4">

View File

@@ -1,57 +1,7 @@
import React from "react";
const contactInfo = {
location: "Amarillo, TX",
phone: "806.654.2813",
email: "jholt1008@gmail.com",
linkedin: "https://www.linkedin.com/in/jody-holt-9b19b0256",
};
const summary = `Detail-oriented software developer skilled in building full-stack applications using React, TypeScript, Node/Express, SQL, and Docker. Experienced in designing responsive user interfaces, structuring maintainable front-end architectures, and developing reliable, modular APIs. Strong communicator with proven ability to solve problems quickly, learn new technologies efficiently, and deliver clean, scalable code across multiple projects.`;
const skills = [
{
category: "Front-End Development",
items: ["React", "TypeScript", "Responsive UI/UX", "Component Architecture", "Entity Framework Core", "TailwindCSS"],
},
{
category: "Back-End & APIs",
items: ["Node.js", "Express.js", "RESTful API", "Authentication Flows", "Data Validation", "C#", ".NET Core"],
},
{
category: "Database & Data Modeling",
items: ["SQL", "PostgreSQL", "CRUD Operations", "Query Optimization", "Object-Oriented Analysis & Design"],
},
{
category: "DevOps & Tools",
items: ["Docker Compose", "Git/GitHub", "Software Migration", "Multi-Container Setups"],
},
{
category: "Software Engineering",
items: ["Clear Communication", "Modular Code Design", "Collaboration", "Rapid Learning", "Problem-Solving"],
},
];
const accomplishments = [
"Meta's Front-End Web Development and Data Engineering certificate programs",
"Built responsive React applications featuring structured component trees & dynamic routing",
"Designed SQL databases with optimal CRUD operations & well-structured queries",
"Containerized full-stack apps with Docker Compose for optimal scaling, resolved network, environment, version control, and dependency issues",
"Created reusable UI components and interactive features that improved consistency and flow, user-friendly animations and enticing UX",
];
const workHistory = [
{ title: "Training Specialist", company: "Subway", location: "Canyon, TX", dates: "2024Present" },
{ title: "Head Lifeguard", company: "Johnson Park Youth Center", location: "Borger, TX", dates: "Seasonal 20222025" },
{ title: "Sacker/Grocery Stocker", company: "United Supermarkets", location: "Canyon, TX", dates: "20232024" },
];
const education = [
{ degree: "M.S. in Computer Information Systems and Business Analytics", school: "West Texas A&M University", date: "May 2027" },
{ degree: "B.S. in Computer Information Systems", school: "West Texas A&M University", date: "May 2026" },
];
import { resumeData } from "../data/resume";
export function Resume() {
const { contactInfo, summary, skills, certifications, projects, workHistory, education } = resumeData;
return (
<div className="mx-auto max-w-5xl px-4 py-16 md:py-24">
{/* Header */}
@@ -59,14 +9,22 @@ export function Resume() {
<h2 className="text-4xl md:text-5xl font-extrabold font-title text-text mb-3">Resume</h2>
<div className="flex flex-wrap justify-center gap-3 text-sm text-text/70">
<span>{contactInfo.location}</span>
{contactInfo.website && (
<>
<span className="hidden sm:inline text-primary"></span>
<a href={`tel:${contactInfo.phone.replace(/\./g, "")}`} className="hover:text-primary anim-base">
{contactInfo.phone}
<a href={contactInfo.website} className="hover:text-primary anim-base">
{new URL(contactInfo.website).hostname}
</a>
</>
)}
{contactInfo.linkedin && (
<>
<span className="hidden sm:inline text-primary"></span>
<a href={`mailto:${contactInfo.email}`} className="hover:text-primary anim-base">
{contactInfo.email}
<a href={contactInfo.linkedin} className="hover:text-primary anim-base">
LinkedIn
</a>
</>
)}
</div>
</div>
@@ -101,11 +59,12 @@ export function Resume() {
</div>
</section>
{/* Accomplishments */}
{/* Certifications */}
{certifications && certifications.length > 0 && (
<section className="mb-10 anim-fade-in">
<SectionTitle>Professional Accomplishments</SectionTitle>
<SectionTitle>Certifications</SectionTitle>
<ul className="space-y-3">
{accomplishments.map((item, i) => (
{certifications.map((item, i) => (
<li key={i} className="flex gap-3 text-text/85">
<span className="mt-2 h-2 w-2 shrink-0 rounded-full bg-primary" />
<span>{item}</span>
@@ -113,6 +72,34 @@ export function Resume() {
))}
</ul>
</section>
)}
{/* Projects */}
{projects && projects.length > 0 && (
<section className="mb-10 anim-fade-in">
<SectionTitle>Projects</SectionTitle>
<div className="space-y-4">
{projects.map((project) => (
<div
key={project.name}
className="rounded-lg border border-secondary bg-secondary/20 p-4 anim-base hover:border-primary/50"
>
<h4 className="font-semibold text-text">
{project.name} <span className="text-text/70">| {project.stack}</span>
</h4>
<ul className="mt-2 space-y-2">
{project.bullets.map((b, i) => (
<li key={i} className="flex gap-3 text-text/85">
<span className="mt-2 h-2 w-2 shrink-0 rounded-full bg-primary" />
<span>{b}</span>
</li>
))}
</ul>
</div>
))}
</div>
</section>
)}
{/* Work History */}
<section className="mb-10 anim-fade-in">
@@ -121,8 +108,9 @@ export function Resume() {
{workHistory.map((job, i) => (
<div
key={i}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between rounded-lg border border-secondary bg-secondary/20 p-4 anim-base hover:border-primary/50"
className="flex flex-col rounded-lg border border-secondary bg-secondary/20 p-4 anim-base hover:border-primary/50"
>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
<div>
<h4 className="font-semibold text-text">{job.title}</h4>
<p className="text-text/70 text-sm">
@@ -131,6 +119,17 @@ export function Resume() {
</div>
<span className="mt-2 sm:mt-0 text-sm text-primary font-medium">{job.dates}</span>
</div>
{job.bullets && job.bullets.length > 0 && (
<ul className="mt-2 space-y-2">
{job.bullets.map((b, idx) => (
<li key={idx} className="flex gap-3 text-text/85">
<span className="mt-2 h-2 w-2 shrink-0 rounded-full bg-primary" />
<span>{b}</span>
</li>
))}
</ul>
)}
</div>
))}
</div>
</section>
@@ -146,7 +145,10 @@ export function Resume() {
>
<div>
<h4 className="font-semibold text-text">{edu.degree}</h4>
<p className="text-text/70 text-sm">{edu.school}</p>
<p className="text-text/70 text-sm">
{edu.school}
{edu.details ? ` | ${edu.details}` : ""}
</p>
</div>
<span className="mt-2 sm:mt-0 text-sm text-primary font-medium">{edu.date}</span>
</div>

View File

@@ -1,7 +1,7 @@
import React from "react";
import type { PropsWithChildren } from "react";
export function Section({ id, children }: React.PropsWithChildren<{ id: string }>) {
export function Section({ id, children }: PropsWithChildren<{ id: string }>) {
return (
<section id={id} className="scroll-mt-24">
{children}

View File

@@ -1,5 +1,5 @@
// ThemeToggle.tsx
import React from "react";
import { useState } from "react";
import { useTheme } from "../hooks/useTheme";
// Actual primary colors for each theme
@@ -13,6 +13,7 @@ const themeColors: Record<string, { primary: string; label: string }> = {
export function ThemeToggle({ compact = false }: { compact?: boolean }) {
const { theme, setTheme } = useTheme();
const [open, setOpen] = useState(false);
const themes = ["a", "b", "c", "d", "e"] as const;
const crossfadeTo = (next: typeof themes[number]) => {
@@ -26,7 +27,8 @@ export function ThemeToggle({ compact = false }: { compact?: boolean }) {
root.classList.add("hero-xfade");
// 3) switch theme (your existing logic)
setTheme(next as any);
setTheme(next);
setOpen(false);
// 4) remove crossfade flag after the animation
window.setTimeout(() => {
@@ -37,26 +39,31 @@ export function ThemeToggle({ compact = false }: { compact?: boolean }) {
return (
<div className="relative inline-block text-text">
<details className="group">
<summary className="cursor-pointer select-none list-none inline-flex items-center gap-2 rounded px-3 py-1.5 bg-secondary/70 hover:bg-secondary focus:outline-none anim-base hover-pop">
<button
type="button"
aria-expanded={open}
aria-haspopup="listbox"
className="cursor-pointer select-none inline-flex items-center gap-2 rounded px-3 py-1.5 bg-secondary/70 hover:bg-secondary focus:outline-none anim-base hover-pop"
onClick={() => setOpen((v) => !v)}
>
<span className="font-medium">{compact ? "Theme" : "Toggle Theme"}</span>
<span aria-hidden></span>
</summary>
<span aria-hidden>{open ? "▴" : "▾"}</span>
</button>
<div
className="
className={`
absolute top-full mt-2 z-[70] rounded-lg border border-secondary bg-bg/95 p-2 shadow-xl backdrop-blur
left-4 right-4 mx-auto w-[calc(100vw-2rem)] max-w-[18rem]
md:left-auto md:right-0 md:mx-0 md:w-44 md:max-w-none
origin-top scale-y-95 opacity-0 translate-y-[-4px]
pointer-events-none transition-all duration-300 ease-out
group-open:opacity-100 group-open:scale-y-100 group-open:translate-y-0 group-open:pointer-events-auto
"
origin-top transition-[opacity,transform] duration-200 ease-out
${open ? "opacity-100 scale-100 translate-y-0 pointer-events-auto" : "opacity-0 scale-95 -translate-y-1 pointer-events-none"}
`}
>
<ul className="space-y-1">
<ul className="space-y-1" role="listbox">
{themes.map((t) => (
<li key={t}>
<button
type="button"
onClick={() => crossfadeTo(t)}
className={`w-full rounded px-3 py-2 text-left hover:bg-secondary/60 anim-base ${
theme === t ? "outline outline-1 outline-primary" : ""
@@ -72,7 +79,6 @@ export function ThemeToggle({ compact = false }: { compact?: boolean }) {
))}
</ul>
</div>
</details>
</div>
);
}

77
src/data/resume.ts Normal file
View File

@@ -0,0 +1,77 @@
export type ResumeData = {
contactInfo: {
name?: string;
location: string;
website?: string;
linkedin?: string;
};
summary: string;
skills: Array<{ category: string; items: string[] }>;
certifications?: string[];
projects?: Array<{ name: string; stack: string; bullets: string[] }>;
accomplishments?: string[];
workHistory: Array<{ title: string; company: string; location: string; dates: string; bullets?: string[] }>;
education: Array<{ degree: string; school: string; date: string; details?: string }>;
};
export const resumeData: ResumeData = {
contactInfo: {
name: "Jody Holt",
location: "Amarillo, TX",
website: "https://www.jodyholt.com",
linkedin: "https://www.linkedin.com/in/jody-holt-cis",
},
summary:
"Detail-oriented software developer with experience building full-stack applications using React, TypeScript, SQL, Express, and Docker. Skilled in responsive UI, modular API design, and writing scalable code. Strong communicator known for learning new technologies quickly and solving problems efficiently.",
skills: [
{ category: "Front End", items: ["React", "TypeScript", "JavaScript", "Responsive Design", "Component Architecture", "Vite"] },
{ category: "Back End", items: ["Node.js", "Express", "REST APIs", "Authentication", "Input Validation", "JWT"] },
{ category: "Database", items: ["SQL", "Prisma", "CRUD Operations", "Query Optimization", "Database Schema Design", "Data Modeling"] },
{ category: "Tools", items: ["Git/GitHub", "Docker", "Docker Compose", "Postman"] },
],
certifications: [
"Meta Front-End Developer Certificate (Coursera)",
"Meta Data Engineering Certificate (Coursera)",
],
projects: [
{
name: "SkyMoney Budgeting App (Beta)",
stack: "React, TypeScript, Node.js, Prisma, PostgreSQL",
bullets: [
"Built a full-stack budgeting platform with 17 screens, reusable UI components, and 47 REST endpoints for income, transactions, variable categories, and payment plan automation.",
"Implemented core budgeting logic including auto-funding, overdue prioritization, partial payments, and bill reconciliation workflows.",
"Designed a relational database schema using 7 Prisma models with all writes scoped to user ID.",
"Containerized the API, PostgreSQL, Caddy reverse proxy, and scheduled workers using Docker Compose for production-ready deployment.",
],
},
{
name: "React Portfolio Website",
stack: "React, TypeScript, Vite, Responsive UI",
bullets: [
"Built a single-page portfolio with 3 core sections and 7 reusable components, structured for expansion.",
"Implemented interactive UI including a 5-theme color system, navigation, and mobile responsiveness.",
"Organized code using TypeScript, reusable component patterns, and a custom theme hook.",
],
},
],
workHistory: [
{
title: "Sandwich Artist",
company: "Subway",
location: "Canyon, TX",
dates: "Sep 2024 - Present",
bullets: ["Maintained fast and accurate customer service by completing orders in a high-volume environment."],
},
{
title: "Head Lifeguard (Seasonal)",
company: "Johnson Park Youth Center",
location: "Borger, TX",
dates: "May 2022 - Aug 2025",
bullets: ["Led safety operations by monitoring swimmers and enforcing policies during high-traffic shifts."],
},
],
education: [
{ degree: "B.B.A. Computer Information Systems", school: "West Texas A&M University", date: "May 2026", details: "GPA: 3.20/4.0" },
{ degree: "M.S. Computer Information Systems & Business Analytics", school: "West Texas A&M University", date: "May 2027" },
],
};

30
src/types/turnstile.d.ts vendored Normal file
View File

@@ -0,0 +1,30 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_CONTACT_API_URL?: string;
readonly VITE_TURNSTILE_SITE_KEY?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
type TurnstileWidgetId = string | number;
type TurnstileRenderOptions = {
sitekey: string;
action?: string;
callback?: (token: string) => void;
"expired-callback"?: () => void;
"error-callback"?: () => void;
};
type TurnstileApi = {
render: (container: string | HTMLElement, options: TurnstileRenderOptions) => TurnstileWidgetId;
reset: (widgetId?: TurnstileWidgetId) => void;
remove: (widgetId: TurnstileWidgetId) => void;
};
interface Window {
turnstile?: TurnstileApi;
}

View File