Compare commits
25
Commits
main
..
7d9c0014ed
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d9c0014ed | ||
|
|
3caf1e30b0 | ||
|
|
b4ef9fd2e0 | ||
|
|
66f63523e2 | ||
|
|
9a84d4b78c | ||
|
|
f559a28b74 | ||
|
|
4e82497640 | ||
|
|
136c0e61db | ||
|
|
39507d4bd8 | ||
|
|
0fb0877a92 | ||
|
|
6f7efed5e5 | ||
|
|
594aa96dc7 | ||
|
|
d5a2914915 | ||
|
|
9181ab2fed | ||
|
|
31b9e665b5 | ||
|
|
2a56bd1b3a | ||
|
|
5489adf81e | ||
|
|
6975f5aeab | ||
|
|
da75479555 | ||
|
|
261bed6e25 | ||
|
|
0c4d1dd9c2 | ||
|
|
584a4325af | ||
|
|
fec156f6d3 | ||
|
|
fb85b0d834 | ||
|
|
8f86e13dfc |
@@ -1,2 +0,0 @@
|
||||
VITE_CONTACT_API_URL=/api/contact
|
||||
VITE_TURNSTILE_SITE_KEY=0x4AAAAAACfQya1R13nGeuOy
|
||||
@@ -7,58 +7,19 @@ on:
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: vps-host
|
||||
env:
|
||||
CONTACT_HEALTH_URL: https://jodyholt.com/api/health
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install Frontend Dependencies
|
||||
run: npm ci
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Build Frontend
|
||||
run: npm run build
|
||||
- name: Build with Vite
|
||||
|
||||
run: npx vite 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
|
||||
- name: Sync 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: |
|
||||
set -euo pipefail
|
||||
SYSTEMCTL_BIN="/usr/bin/systemctl"
|
||||
if [ ! -x "$SYSTEMCTL_BIN" ]; then
|
||||
SYSTEMCTL_BIN="/bin/systemctl"
|
||||
fi
|
||||
sudo -n "$SYSTEMCTL_BIN" restart jody-contact-api
|
||||
sudo -n "$SYSTEMCTL_BIN" is-active --quiet jody-contact-api
|
||||
echo "jody-contact-api service is active"
|
||||
|
||||
- name: Health Check Contact API
|
||||
run: |
|
||||
curl --fail --show-error --silent \
|
||||
--retry 8 \
|
||||
--retry-delay 2 \
|
||||
--retry-all-errors \
|
||||
"$CONTACT_HEALTH_URL"
|
||||
cp -r dist/* /var/www/jody/dist/
|
||||
+19
-29
@@ -1,43 +1,33 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
contact-api/node_modules/
|
||||
|
||||
# Build artifacts
|
||||
dist/
|
||||
build/
|
||||
contact-api/dist/
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
.vite/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Environment files (keep examples)
|
||||
# Build output
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Vite cache
|
||||
.vite/
|
||||
|
||||
# Archives and local release bundles
|
||||
*.tgz
|
||||
*.tar
|
||||
*.tar.gz
|
||||
|
||||
# Tool caches
|
||||
.eslintcache
|
||||
.nyc_output/
|
||||
.tmp/
|
||||
|
||||
# Editor / IDE
|
||||
# VSCode settings
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Log files
|
||||
*.log
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.*.local
|
||||
|
||||
# OS generated
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Optional: lock files (if you use one package manager)
|
||||
# Uncomment the ones you're not using
|
||||
# yarn.lock
|
||||
# package-lock.json
|
||||
# pnpm-lock.yamlw
|
||||
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Jody Holt
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -23,7 +23,7 @@ This site demonstrates my personality, skills, and presents the tone I provide t
|
||||
|
||||
```bash
|
||||
src/
|
||||
|-assets/ # Images
|
||||
|-assets/ # Images
|
||||
|-components/ #Reusable UI components
|
||||
|-hooks/ #Custom React Hooks
|
||||
|-styles/ #Tailwind + custom theme CSS
|
||||
@@ -59,38 +59,9 @@ 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`
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License.
|
||||
See `LICENSE` for full text.
|
||||
|
||||
## Author
|
||||
|
||||
### Jody Holt
|
||||
### Frontend Developer • Passion Pioneer
|
||||
#### [GitHub](https://github.com/Ricearoni1245) • [LinkedIn](https://www.linkedin.com/in/jody-holt-9b19b0256)
|
||||
#### [GitHub](https://github.com/Ricearoni1245) • [LinkedIn](https://www.linkedin.com/in/jody-holt-9b19b0256) • [Email](mailto:jholt1008@gmail.com)
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
NODE_ENV=production
|
||||
PORT=8787
|
||||
CONTACT_ALLOWED_ORIGIN=https://jodyholt.com,https://www.jodyholt.com
|
||||
TURNSTILE_EXPECTED_HOSTNAME=jodyholt.com,www.jodyholt.com
|
||||
TURNSTILE_SECRET_KEY=0x4AAAAAACfQyRwRzwsEMIfVtCSkjz7__Yc
|
||||
TURNSTILE_EXPECTED_ACTION=contact_form
|
||||
SMTP_HOST=mail.jodyholt.com
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_REQUIRE_TLS=true
|
||||
SMTP_USER=portfolio-smtp
|
||||
SMTP_PASS=portfolio124521!
|
||||
MAIL_FROM_NAME=Portfolio Contact
|
||||
MAIL_FROM_ADDRESS=contact@jodyholt.com
|
||||
MAIL_TO_ADDRESS=you@jodyholt.com
|
||||
MAIL_SUBJECT_PREFIX=[Portfolio Contact]
|
||||
RATE_LIMIT_WINDOW_MS=600000
|
||||
RATE_LIMIT_MAX=5
|
||||
HONEYPOT_FIELD=website
|
||||
MIN_SUBMIT_TIME_MS=3000
|
||||
Generated
-1755
File diff suppressed because it is too large
Load Diff
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
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),
|
||||
// Comma-separated list of allowed browser origins, e.g.
|
||||
// https://jodyholt.com,https://www.jodyholt.com
|
||||
CONTACT_ALLOWED_ORIGIN: z.string().min(1),
|
||||
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;
|
||||
@@ -1,163 +0,0 @@
|
||||
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);
|
||||
|
||||
const normalizeOrigin = (value: string): string => {
|
||||
const cleaned = value
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, "")
|
||||
.replace(/\/+$/g, "");
|
||||
|
||||
// Some clients can emit explicit default ports. URL.origin normalizes them.
|
||||
try {
|
||||
return new URL(cleaned).origin.toLowerCase();
|
||||
} catch {
|
||||
return cleaned.toLowerCase();
|
||||
}
|
||||
};
|
||||
|
||||
const allowedOrigins = config.CONTACT_ALLOWED_ORIGIN
|
||||
.split(",")
|
||||
.map(normalizeOrigin)
|
||||
.filter((value, index, all) => value.length > 0 && all.indexOf(value) === index);
|
||||
|
||||
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) {
|
||||
callback(null, true);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedOrigin = normalizeOrigin(origin);
|
||||
if (allowedOrigins.includes(normalizedOrigin)) {
|
||||
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);
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { config } from "./config.js";
|
||||
|
||||
type TurnstileVerifyResponse = {
|
||||
success: boolean;
|
||||
hostname?: string;
|
||||
action?: string;
|
||||
"error-codes"?: string[];
|
||||
};
|
||||
|
||||
const normalizeHostname = (value: string): string =>
|
||||
value
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, "")
|
||||
.replace(/\.+$/g, "")
|
||||
.toLowerCase();
|
||||
|
||||
const expectedHostnames = config.TURNSTILE_EXPECTED_HOSTNAME
|
||||
.split(",")
|
||||
.map(normalizeHostname)
|
||||
.filter((value, index, all) => value.length > 0 && all.indexOf(value) === index);
|
||||
|
||||
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 || !expectedHostnames.includes(normalizeHostname(result.hostname))) {
|
||||
return { ok: false, reason: "hostname_mismatch" };
|
||||
}
|
||||
|
||||
if (result.action !== config.TURNSTILE_EXPECTED_ACTION) {
|
||||
return { ok: false, reason: "action_mismatch" };
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
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>;
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -8,11 +8,6 @@
|
||||
</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>
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
[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
|
||||
@@ -1,9 +0,0 @@
|
||||
# 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;
|
||||
}
|
||||
@@ -6,8 +6,6 @@
|
||||
"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"
|
||||
},
|
||||
|
||||
+2
-5
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Navbar } from "./components/Navbar";
|
||||
import { Section } from "./components/Section";
|
||||
import { Hero } from "./components/Hero";
|
||||
@@ -7,10 +7,9 @@ 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", "contact"], []);
|
||||
const sections = useMemo(() => ["home", "about", "projects", "experience"], []);
|
||||
const refs = useRef<Record<string, HTMLElement | null>>({});
|
||||
const [active, setActive] = useState<string>(sections[0]);
|
||||
|
||||
@@ -56,8 +55,6 @@ return (
|
||||
<Section id="projects"><Projects /></Section>
|
||||
<GradientBand />
|
||||
<Section id="experience"><Resume /></Section>
|
||||
<GradientBand />
|
||||
<Section id="contact"><Contact /></Section>
|
||||
</main>
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 35 KiB |
Binary file not shown.
Binary file not shown.
@@ -1,3 +1,4 @@
|
||||
import React from "react";
|
||||
import aboutImg from "../assets/img/about-img.png";
|
||||
|
||||
export function AboutMe() {
|
||||
|
||||
@@ -1,340 +0,0 @@
|
||||
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'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 && !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>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,16 @@
|
||||
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 };
|
||||
@@ -50,12 +55,6 @@ 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 md:justify-end">
|
||||
@@ -63,8 +62,8 @@ export function Footer({
|
||||
<a
|
||||
key={s.label}
|
||||
href={s.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
target={s.href.startsWith("mailto:") || s.href.startsWith("tel:") ? undefined : "_blank"}
|
||||
rel={s.href.startsWith("mailto:") || s.href.startsWith("tel:") ? undefined : "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}
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
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"
|
||||
@@ -52,8 +57,8 @@ export function Hero() {
|
||||
<a
|
||||
key={a.label}
|
||||
href={a.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
target={a.href.startsWith("mailto:") || a.href.startsWith("tel:") ? undefined : "_blank"}
|
||||
rel={a.href.startsWith("mailto:") || a.href.startsWith("tel:") ? undefined : "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
|
||||
@@ -122,8 +127,8 @@ export function Hero() {
|
||||
<a
|
||||
key={a.label}
|
||||
href={a.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
target={a.href.startsWith("mailto:") || a.href.startsWith("tel:") ? undefined : "_blank"}
|
||||
rel={a.href.startsWith("mailto:") || a.href.startsWith("tel:") ? undefined : "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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import React, { useState } from "react";
|
||||
import { ThemeToggle } from "./ThemeToggle";
|
||||
|
||||
export function Navbar({ onNav }: { onNav: (id: string) => void }) {
|
||||
@@ -8,7 +8,6 @@ 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) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// =====================================
|
||||
import React from "react";
|
||||
|
||||
|
||||
export function Placeholder({ title }: { title: string }) {
|
||||
@@ -14,4 +15,4 @@ Card {i + 1}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import React, { 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";
|
||||
@@ -7,10 +7,7 @@ import skymoneyvideo from "../assets/video/Skymoney-video.mp4";
|
||||
import skymoneyvideoMobile from "../assets/video/Skymoney-mobile-video.mp4";
|
||||
import millervideo from "../assets/video/500nmain-video.mp4";
|
||||
import millervideoMobile from "../assets/video/500nmain-mobile-video.mp4";
|
||||
import themeTesterCover from "../assets/img/theme_tester_img.png";
|
||||
import themeTesterCoverMobile from "../assets/img/theme_tester_mobile_img.png";
|
||||
import themeTesterVideo from "../assets/video/themeTesterVideo.mp4";
|
||||
import themeTesterVideoMobile from "../assets/video/themeTesterVideoMobile.mp4";
|
||||
|
||||
type Project = {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -22,7 +19,6 @@ type Project = {
|
||||
techStack: string[];
|
||||
liveUrl?: string;
|
||||
beta?: boolean;
|
||||
inDev?: boolean;
|
||||
note?: string;
|
||||
comingSoon?: boolean;
|
||||
};
|
||||
@@ -40,7 +36,6 @@ const projects: Project[] = [
|
||||
techStack: ["React", "TypeScript", "Node.js", "PostgreSQL"],
|
||||
liveUrl: "https://skymoneybudget.com",
|
||||
beta: true,
|
||||
inDev: true,
|
||||
note: "Contact Jody for beta access.",
|
||||
},
|
||||
{
|
||||
@@ -55,22 +50,6 @@ const projects: Project[] = [
|
||||
techStack: ["HTML", "CSS", "JQuery"],
|
||||
liveUrl: "https://500nmain806.com",
|
||||
},
|
||||
{
|
||||
id: "color-theme-tester",
|
||||
title: "Color Theme UI Tester",
|
||||
description:
|
||||
"A UI Color Theme Tester that visualizes color schemes, fonts, and images on a mock UI.",
|
||||
coverImage: themeTesterCover,
|
||||
coverImageMobile: themeTesterCoverMobile,
|
||||
video: themeTesterVideo,
|
||||
videoMobile: themeTesterVideoMobile,
|
||||
techStack: ["React", "Tailwind", "Typescript"],
|
||||
liveUrl: "https://github.com/Ricearoni1245/Color-Theme-UI-Tester",
|
||||
beta: false,
|
||||
inDev: true,
|
||||
note: "Stay tuned for progress!"
|
||||
}
|
||||
|
||||
];
|
||||
|
||||
function VideoModal({
|
||||
@@ -184,15 +163,8 @@ function ProjectCard({
|
||||
<div className="absolute top-3 right-3 px-3 py-1 rounded-full bg-primary text-white text-xs font-bold uppercase tracking-wider">
|
||||
Beta
|
||||
</div>
|
||||
|
||||
)}
|
||||
{project.inDev && (
|
||||
<div className="absolute top-3 left-3 px-3 py-1 rounded-full bg-tertiary text-white text-xs font-bold uppercase tracking-wider">
|
||||
In Progress
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cover Image - Mobile */}
|
||||
<div className="relative aspect-[9/16] overflow-hidden md:hidden mx-4 my-4 rounded-xl">
|
||||
@@ -225,11 +197,6 @@ function ProjectCard({
|
||||
Beta
|
||||
</div>
|
||||
)}
|
||||
{project.inDev && (
|
||||
<div className="absolute top-3 left-3 px-3 py-1 rounded-full bg-tertiary text-white text-xs font-bold uppercase tracking-wider">
|
||||
In Progress
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import React from "react";
|
||||
import { resumeData } from "../data/resume";
|
||||
|
||||
export function Resume() {
|
||||
@@ -9,22 +10,14 @@ 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.phone && (
|
||||
<>
|
||||
<span className="hidden sm:inline text-primary">•</span>
|
||||
<a href={`tel:${contactInfo.phone.replace(/\D/g, "")}`} className="hover:text-primary anim-base">
|
||||
{contactInfo.phone}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
{contactInfo.email && (
|
||||
<>
|
||||
<span className="hidden sm:inline text-primary">•</span>
|
||||
<a href={`mailto:${contactInfo.email}`} className="hover:text-primary anim-base">
|
||||
{contactInfo.email}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
<span className="hidden sm:inline text-primary">•</span>
|
||||
<a href={`tel:${contactInfo.phone.replace(/\./g, "")}`} className="hover:text-primary anim-base">
|
||||
{contactInfo.phone}
|
||||
</a>
|
||||
<span className="hidden sm:inline text-primary">•</span>
|
||||
<a href={`mailto:${contactInfo.email}`} className="hover:text-primary anim-base">
|
||||
{contactInfo.email}
|
||||
</a>
|
||||
{contactInfo.website && (
|
||||
<>
|
||||
<span className="hidden sm:inline text-primary">•</span>
|
||||
@@ -41,14 +34,6 @@ export function Resume() {
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
{contactInfo.github && (
|
||||
<>
|
||||
<span className="hidden sm:inline text-primary">•</span>
|
||||
<a href={contactInfo.github} className="hover:text-primary anim-base">
|
||||
GitHub
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import React from "react";
|
||||
|
||||
|
||||
export function Section({ id, children }: PropsWithChildren<{ id: string }>) {
|
||||
export function Section({ id, children }: React.PropsWithChildren<{ id: string }>) {
|
||||
return (
|
||||
<section id={id} className="scroll-mt-24">
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// ThemeToggle.tsx
|
||||
import { useState } from "react";
|
||||
import React, { useState } from "react";
|
||||
import { useTheme } from "../hooks/useTheme";
|
||||
|
||||
// Actual primary colors for each theme
|
||||
@@ -27,7 +27,7 @@ export function ThemeToggle({ compact = false }: { compact?: boolean }) {
|
||||
root.classList.add("hero-xfade");
|
||||
|
||||
// 3) switch theme (your existing logic)
|
||||
setTheme(next);
|
||||
setTheme(next as any);
|
||||
setOpen(false);
|
||||
|
||||
// 4) remove crossfade flag after the animation
|
||||
|
||||
+30
-43
@@ -2,11 +2,10 @@ export type ResumeData = {
|
||||
contactInfo: {
|
||||
name?: string;
|
||||
location: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
website?: string;
|
||||
linkedin?: string;
|
||||
github?: string;
|
||||
};
|
||||
summary: string;
|
||||
skills: Array<{ category: string; items: string[] }>;
|
||||
@@ -22,69 +21,57 @@ export const resumeData: ResumeData = {
|
||||
name: "Jody Holt",
|
||||
location: "Amarillo, TX",
|
||||
phone: "806.654.2813",
|
||||
email: "jody@jodyholt.com",
|
||||
email: "jholt1008@gmail.com",
|
||||
website: "https://www.jodyholt.com",
|
||||
linkedin: "https://www.linkedin.com/in/jody-holt-cis",
|
||||
github: "https://github.com/Ricearoni1245",
|
||||
},
|
||||
summary:
|
||||
"Detail-oriented full-stack software developer with experience in building secure, responsive, and maintainable full-stack applications using React, TypeScript, Node.js, Express, ASP.NET Core, C#, SQL, Prisma, and Docker. Skilled in REST API development, database schema design, secure authentication patterns, reusable UI architecture, modular code design, and enterprise application development using IIS, Windows Server, and Active Directory Integration. Known for learning new technologies quickly, gathering requirements from stakeholders or product owners, modernizing legacy systems, and delivering reliable solutions that align with user and business needs.",
|
||||
"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: "Languages", items: ["TypeScript", "JavaScript", "Python", "C#", "SQL"] },
|
||||
{ category: "Front End", items: ["React", "CSS (Flexbox/Grid)", "Responsive Design", "Component Architecture", "Vite"] },
|
||||
{ category: "Back End", items: ["ASP.NET Core", "Node.js", "Express.js", "REST APIs", "JWT Authentication", "Role-based Authorization", "Input Validation", "Error Handling", "Business Logic Development"] },
|
||||
{ category: "Database", items: ["PostgreSQL", "Prisma ORM", "CRUD", "Query Optimization", "Data Modeling", "Relational Database Design"] },
|
||||
{ category: "Software Engineering", items: ["Agile/Scrum", "Requirements Gathering", "Object-Oriented Analysis and Design", "Stakeholder Collaboration", "Modular Code Design", "Maintainable Code"] },
|
||||
{ category: "Enterprise Systems", items: ["Windows Server", "IIS Application Deployment", "Active Directory Authentication", "Windows Integrated Authentication"] },
|
||||
{ category: "Tools", items: ["Git/GitHub/GitLab", "Docker", "Docker Compose", "Postman"] },
|
||||
{ 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",
|
||||
"Meta Data Engineering Certificate",
|
||||
"AI Software Engineering Certificate",
|
||||
"Meta Front-End Developer Certificate (Coursera)",
|
||||
"Meta Data Engineering Certificate (Coursera)",
|
||||
],
|
||||
projects: [
|
||||
{
|
||||
name: "SkyMoney Budgeting App (Beta)",
|
||||
stack: "React, TypeScript, Node.js, Prisma, PostgreSQL, Docker",
|
||||
stack: "React, TypeScript, Node.js, Prisma, PostgreSQL",
|
||||
bullets: [
|
||||
"Built a full stack budgeting platform with 17 screens, 23 reusable UI components, and 47 REST endpoints supporting income tracking, transaction management, variable spending categories, and payment plan automation.",
|
||||
"Implemented core budgeting logic including auto-funding, overdue payment prioritization, partial payments, bill reconciliation, and automated financial workflow management.",
|
||||
"Designed a relational PostgreSQL database schema using 7 Prisma models, ensuring user-scoped writes, structured data relationships, and secure multi-user data access patterns.",
|
||||
"Containerized background workers with Docker to support production-style deployment, process isolation, and scalable backend task execution.",
|
||||
"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: "Responsive Portfolio Website",
|
||||
stack: "React, TypeScript, Tailwind CSS, Vite",
|
||||
name: "React Portfolio Website",
|
||||
stack: "React, TypeScript, Vite, Responsive UI",
|
||||
bullets: [
|
||||
"Developed an expandable single-page portfolio using React, TypeScript, Tailwind CSS, and Vite, with 3 primary sections and 7+ reusable components.",
|
||||
"Implemented responsive UI features including custom theming, navigation, interactive page elements, and mobile-friendly layouts.",
|
||||
"Organized frontend code using TypeScript, reusable component patterns, custom hooks, and maintainable UI architecture.",
|
||||
"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: "IT Applications Intern",
|
||||
company: "Golden Spread Electrical Coop.",
|
||||
location: "Amarillo, TX",
|
||||
dates: "May 2026 - Aug 2026",
|
||||
bullets: [
|
||||
"Modernized a legacy internal merchandise marketplace using ASP.NET Core and C#, enabling employees to redeem company-branded items through a custom internal credit system.",
|
||||
"Analyzed the existing marketplace, legacy workflows, and functional requirements to identify gaps, preserve key business functionality, and define improvements for the revamped application.",
|
||||
"Researched marketplace platforms, then customized the selected solution to support internal purchasing rules, company branding, custom currency handling, and administrative workflows.",
|
||||
"Configured and deployed the application in a Windows Server environment using Internet Information Services (IIS).",
|
||||
"Implemented enterprise authentication using Active Directory integration and Windows Integrated Authentication to support secure internal employee access and role-based authorization.",
|
||||
"Presented a working demo to supervisors, collected feedback, and iterated features to improve functionality, usability, and maintainability to align with internal operations.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Training Specialist",
|
||||
title: "Sandwich Artist",
|
||||
company: "Subway",
|
||||
location: "Canyon, TX",
|
||||
dates: "Sep 2024 - Jan 2026",
|
||||
bullets: ["Trained new hires, communicated process updates, and supported operational improvements to strengthen team consistency, documentation accuracy, and workflow efficiency."],
|
||||
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: [
|
||||
|
||||
Vendored
-30
@@ -1,30 +0,0 @@
|
||||
/// <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;
|
||||
}
|
||||
Reference in New Issue
Block a user