Compare commits
8 Commits
4e82497640
...
feat/conta
| Author | SHA1 | Date | |
|---|---|---|---|
| 228240ec2d | |||
| 87f0443b31 | |||
| 7d9c0014ed | |||
| 3caf1e30b0 | |||
| b4ef9fd2e0 | |||
| 66f63523e2 | |||
| 9a84d4b78c | |||
| f559a28b74 |
@@ -11,15 +11,41 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Frontend Dependencies
|
||||||
run: npm install
|
run: npm ci
|
||||||
|
|
||||||
- name: Build with Vite
|
- name: Build Frontend
|
||||||
|
run: npm run build
|
||||||
run: npx vite build
|
|
||||||
|
|
||||||
- name: Sync Files
|
- 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: |
|
run: |
|
||||||
mkdir -p /var/www/jody/dist
|
mkdir -p /var/www/jody/dist
|
||||||
rm -rf /var/www/jody/dist/*
|
rm -rf /var/www/jody/dist/*
|
||||||
cp -r dist/* /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
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -7,6 +7,7 @@ pnpm-debug.log*
|
|||||||
# Build output
|
# Build output
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
|
contact-api/dist/
|
||||||
|
|
||||||
# Vite cache
|
# Vite cache
|
||||||
.vite/
|
.vite/
|
||||||
@@ -20,6 +21,7 @@ build/
|
|||||||
# Environment variables
|
# Environment variables
|
||||||
.env
|
.env
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
contact-api/.env
|
||||||
|
|
||||||
# OS generated
|
# OS generated
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -29,4 +31,5 @@ Thumbs.db
|
|||||||
# Uncomment the ones you're not using
|
# Uncomment the ones you're not using
|
||||||
# yarn.lock
|
# yarn.lock
|
||||||
# package-lock.json
|
# package-lock.json
|
||||||
# pnpm-lock.yamlw
|
# pnpm-lock.yamlw
|
||||||
|
|
||||||
|
|||||||
28
README.md
28
README.md
@@ -59,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
|
## Author
|
||||||
|
|
||||||
### Jody Holt
|
### Jody Holt
|
||||||
### Frontend Developer • Passion Pioneer
|
### 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
BIN
contact-api-release.tgz
Normal file
Binary file not shown.
1755
contact-api/package-lock.json
generated
Normal file
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
29
contact-api/package.json
Normal 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
41
contact-api/src/config.ts
Normal 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
138
contact-api/src/index.ts
Normal 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
65
contact-api/src/mailer.ts
Normal 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, "&")
|
||||||
|
.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,
|
||||||
|
});
|
||||||
|
}
|
||||||
48
contact-api/src/turnstile.ts
Normal file
48
contact-api/src/turnstile.ts
Normal 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 };
|
||||||
|
}
|
||||||
12
contact-api/src/validation.ts
Normal file
12
contact-api/src/validation.ts
Normal 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
18
contact-api/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
@@ -8,6 +8,11 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<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>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
18
ops/jody-contact-api.service
Normal file
18
ops/jody-contact-api.service
Normal 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
|
||||||
9
ops/nginx-contact-api.conf
Normal file
9
ops/nginx-contact-api.conf
Normal 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;
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
|
"build:api": "npm run build --prefix contact-api",
|
||||||
|
"build:all": "npm run build && npm run build:api",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 { Navbar } from "./components/Navbar";
|
||||||
import { Section } from "./components/Section";
|
import { Section } from "./components/Section";
|
||||||
import { Hero } from "./components/Hero";
|
import { Hero } from "./components/Hero";
|
||||||
@@ -7,9 +7,10 @@ import { Projects } from "./components/Projects";
|
|||||||
import { Resume } from "./components/Resume";
|
import { Resume } from "./components/Resume";
|
||||||
import { Footer } from "./components/Footer";
|
import { Footer } from "./components/Footer";
|
||||||
import { AboutMe } from "./components/AboutMe";
|
import { AboutMe } from "./components/AboutMe";
|
||||||
|
import { Contact } from "./components/Contact";
|
||||||
|
|
||||||
export default function App() {
|
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 refs = useRef<Record<string, HTMLElement | null>>({});
|
||||||
const [active, setActive] = useState<string>(sections[0]);
|
const [active, setActive] = useState<string>(sections[0]);
|
||||||
|
|
||||||
@@ -55,6 +56,8 @@ return (
|
|||||||
<Section id="projects"><Projects /></Section>
|
<Section id="projects"><Projects /></Section>
|
||||||
<GradientBand />
|
<GradientBand />
|
||||||
<Section id="experience"><Resume /></Section>
|
<Section id="experience"><Resume /></Section>
|
||||||
|
<GradientBand />
|
||||||
|
<Section id="contact"><Contact /></Section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import React from "react";
|
|
||||||
import aboutImg from "../assets/img/about-img.png";
|
import aboutImg from "../assets/img/about-img.png";
|
||||||
|
|
||||||
export function AboutMe() {
|
export function AboutMe() {
|
||||||
|
|||||||
342
src/components/Contact.tsx
Normal file
342
src/components/Contact.tsx
Normal 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,16 +1,11 @@
|
|||||||
import React from "react";
|
|
||||||
import githubIcon from "../assets/img/github-icon.png";
|
import githubIcon from "../assets/img/github-icon.png";
|
||||||
import linkedInIcon from "../assets/img/linkedin-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 facebookIcon from "../assets/img/facebook-icon.png";
|
||||||
import phoneIcon from "../assets/img/phone-icon.png";
|
|
||||||
|
|
||||||
const defaultSocials = [
|
const defaultSocials = [
|
||||||
{ label: "GitHub", href: "https://github.com/Ricearoni1245", icon: githubIcon },
|
{ label: "GitHub", href: "https://github.com/Ricearoni1245", icon: githubIcon },
|
||||||
{ label: "LinkedIn", href: "https://www.linkedin.com/in/jody-holt-9b19b0256", icon: linkedInIcon },
|
{ 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: "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 };
|
type Social = { label: string; href: string; icon?: string };
|
||||||
@@ -26,7 +21,7 @@ export function Footer({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<footer className="border-t border-secondary bg-bg px-4 py-10">
|
<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-center md:text-left">
|
||||||
<div className="text-xl md:text-2xl font-extrabold font-name tracking-wide text-text">
|
<div className="text-xl md:text-2xl font-extrabold font-name tracking-wide text-text">
|
||||||
Jody Holt
|
Jody Holt
|
||||||
@@ -36,7 +31,7 @@ export function Footer({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex items-center gap-5">
|
<nav className="flex items-center gap-5 md:justify-center">
|
||||||
<button
|
<button
|
||||||
className="text-text hover:text-primary anim-base"
|
className="text-text hover:text-primary anim-base"
|
||||||
onClick={() => document.getElementById("home")?.scrollIntoView({ behavior: "smooth" })}
|
onClick={() => document.getElementById("home")?.scrollIntoView({ behavior: "smooth" })}
|
||||||
@@ -55,15 +50,21 @@ export function Footer({
|
|||||||
>
|
>
|
||||||
Experience
|
Experience
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="text-text hover:text-primary anim-base"
|
||||||
|
onClick={() => document.getElementById("contact")?.scrollIntoView({ behavior: "smooth" })}
|
||||||
|
>
|
||||||
|
Contact
|
||||||
|
</button>
|
||||||
</nav>
|
</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) => (
|
{socials.map((s) => (
|
||||||
<a
|
<a
|
||||||
key={s.label}
|
key={s.label}
|
||||||
href={s.href}
|
href={s.href}
|
||||||
target={s.href.startsWith("mailto:") || s.href.startsWith("tel:") ? undefined : "_blank"}
|
target="_blank"
|
||||||
rel={s.href.startsWith("mailto:") || s.href.startsWith("tel:") ? undefined : "noopener noreferrer"}
|
rel="noopener noreferrer"
|
||||||
aria-label={s.label}
|
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"
|
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}
|
title={s.label}
|
||||||
|
|||||||
@@ -1,19 +1,14 @@
|
|||||||
import React from "react";
|
|
||||||
import profileImage from "../assets/img/Jody.png";
|
import profileImage from "../assets/img/Jody.png";
|
||||||
import jodyMobile from "../assets/img/Jody-mobile.png";
|
import jodyMobile from "../assets/img/Jody-mobile.png";
|
||||||
import { useTheme } from "../hooks/useTheme";
|
import { useTheme } from "../hooks/useTheme";
|
||||||
import githubIcon from "../assets/img/github-icon.png";
|
import githubIcon from "../assets/img/github-icon.png";
|
||||||
import linkedInIcon from "../assets/img/linkedin-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 facebookIcon from "../assets/img/facebook-icon.png";
|
||||||
import phoneIcon from "../assets/img/phone-icon.png";
|
|
||||||
|
|
||||||
const socialLinks = [
|
const socialLinks = [
|
||||||
{ label: "GitHub", href: "https://github.com/Ricearoni1245", icon: githubIcon },
|
{ label: "GitHub", href: "https://github.com/Ricearoni1245", icon: githubIcon },
|
||||||
{ label: "LinkedIn", href: "https://www.linkedin.com/in/jody-holt-9b19b0256", icon: linkedInIcon },
|
{ 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: "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() {
|
export function Hero() {
|
||||||
const { theme } = useTheme(); // "a" | "b" | "c" | "d" | "e"
|
const { theme } = useTheme(); // "a" | "b" | "c" | "d" | "e"
|
||||||
@@ -57,8 +52,8 @@ export function Hero() {
|
|||||||
<a
|
<a
|
||||||
key={a.label}
|
key={a.label}
|
||||||
href={a.href}
|
href={a.href}
|
||||||
target={a.href.startsWith("mailto:") || a.href.startsWith("tel:") ? undefined : "_blank"}
|
target="_blank"
|
||||||
rel={a.href.startsWith("mailto:") || a.href.startsWith("tel:") ? undefined : "noopener noreferrer"}
|
rel="noopener noreferrer"
|
||||||
aria-label={a.label}
|
aria-label={a.label}
|
||||||
className="inline-flex h-12 w-12 items-center justify-center rounded-lg
|
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
|
border border-secondary/70 bg-secondary/20 text-text anim-base icon-hover
|
||||||
@@ -127,8 +122,8 @@ export function Hero() {
|
|||||||
<a
|
<a
|
||||||
key={a.label}
|
key={a.label}
|
||||||
href={a.href}
|
href={a.href}
|
||||||
target={a.href.startsWith("mailto:") || a.href.startsWith("tel:") ? undefined : "_blank"}
|
target="_blank"
|
||||||
rel={a.href.startsWith("mailto:") || a.href.startsWith("tel:") ? undefined : "noopener noreferrer"}
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center justify-center rounded-xl border
|
className="inline-flex items-center justify-center rounded-xl border
|
||||||
border-secondary/70 bg-secondary/20 text-text transition h-10 w-10
|
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
|
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 React, { useState } from "react";
|
import { useState } from "react";
|
||||||
import { ThemeToggle } from "./ThemeToggle";
|
import { ThemeToggle } from "./ThemeToggle";
|
||||||
|
|
||||||
export function Navbar({ onNav }: { onNav: (id: string) => void }) {
|
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: "about", label: "About" },
|
||||||
{ id: "projects", label: "Projects" },
|
{ id: "projects", label: "Projects" },
|
||||||
{ id: "experience", label: "Resume" },
|
{ id: "experience", label: "Resume" },
|
||||||
|
{ id: "contact", label: "Contact" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleNav = (id: string) => {
|
const handleNav = (id: string) => {
|
||||||
@@ -71,8 +72,10 @@ export function Navbar({ onNav }: { onNav: (id: string) => void }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`md:hidden transition-[max-height] duration-300 ${
|
className={`md:hidden transition-[max-height,opacity,transform] duration-300 ease-out ${
|
||||||
open ? "max-h-96 overflow-visible" : "max-h-0 overflow-hidden"
|
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">
|
<div className="space-y-2 border-t border-secondary bg-bg px-4 py-3">
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
// =====================================
|
// =====================================
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
|
|
||||||
export function Placeholder({ title }: { title: string }) {
|
export function Placeholder({ title }: { title: string }) {
|
||||||
@@ -15,4 +14,4 @@ Card {i + 1}
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 skymoneycover from "../assets/img/Skymoney-cover-img.jpg";
|
||||||
import skymoneycoverMobile from "../assets/img/skymoney-mobile-cover-img.jpg";
|
import skymoneycoverMobile from "../assets/img/skymoney-mobile-cover-img.jpg";
|
||||||
import millercover from "../assets/img/500nmain-cover-img.jpg";
|
import millercover from "../assets/img/500nmain-cover-img.jpg";
|
||||||
@@ -18,13 +18,15 @@ type Project = {
|
|||||||
videoMobile: string;
|
videoMobile: string;
|
||||||
techStack: string[];
|
techStack: string[];
|
||||||
liveUrl?: string;
|
liveUrl?: string;
|
||||||
|
beta?: boolean;
|
||||||
|
note?: string;
|
||||||
comingSoon?: boolean;
|
comingSoon?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const projects: Project[] = [
|
const projects: Project[] = [
|
||||||
{
|
{
|
||||||
id: "skymoney",
|
id: "skymoney",
|
||||||
title: "Skymoney",
|
title: "SkyMoney",
|
||||||
description:
|
description:
|
||||||
"A budgeting app that simulates your bank account to ensure financial discipline.",
|
"A budgeting app that simulates your bank account to ensure financial discipline.",
|
||||||
coverImage: skymoneycover,
|
coverImage: skymoneycover,
|
||||||
@@ -32,7 +34,9 @@ const projects: Project[] = [
|
|||||||
video: skymoneyvideo,
|
video: skymoneyvideo,
|
||||||
videoMobile: skymoneyvideoMobile,
|
videoMobile: skymoneyvideoMobile,
|
||||||
techStack: ["React", "TypeScript", "Node.js", "PostgreSQL"],
|
techStack: ["React", "TypeScript", "Node.js", "PostgreSQL"],
|
||||||
comingSoon: true,
|
liveUrl: "https://skymoneybudget.com",
|
||||||
|
beta: true,
|
||||||
|
note: "Contact Jody for beta access.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "miller-building",
|
id: "miller-building",
|
||||||
@@ -154,10 +158,10 @@ function ProjectCard({
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
{/* Coming Soon Badge */}
|
{/* Beta Badge */}
|
||||||
{project.comingSoon && (
|
{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">
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -187,10 +191,10 @@ function ProjectCard({
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
{/* Coming Soon Badge */}
|
{/* Beta Badge */}
|
||||||
{project.comingSoon && (
|
{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">
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -201,6 +205,9 @@ function ProjectCard({
|
|||||||
<p className="text-text/70 text-sm mb-4 line-clamp-2">
|
<p className="text-text/70 text-sm mb-4 line-clamp-2">
|
||||||
{project.description}
|
{project.description}
|
||||||
</p>
|
</p>
|
||||||
|
{project.note && (
|
||||||
|
<p className="text-text/60 text-xs mb-4">{project.note}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Tech Stack */}
|
{/* Tech Stack */}
|
||||||
<div className="flex flex-wrap gap-2 mb-4">
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
|
|||||||
@@ -1,57 +1,7 @@
|
|||||||
import React from "react";
|
import { resumeData } from "../data/resume";
|
||||||
|
|
||||||
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: "2024–Present" },
|
|
||||||
{ title: "Head Lifeguard", company: "Johnson Park Youth Center", location: "Borger, TX", dates: "Seasonal 2022–2025" },
|
|
||||||
{ title: "Sacker/Grocery Stocker", company: "United Supermarkets", location: "Canyon, TX", dates: "2023–2024" },
|
|
||||||
];
|
|
||||||
|
|
||||||
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" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function Resume() {
|
export function Resume() {
|
||||||
|
const { contactInfo, summary, skills, certifications, projects, workHistory, education } = resumeData;
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-5xl px-4 py-16 md:py-24">
|
<div className="mx-auto max-w-5xl px-4 py-16 md:py-24">
|
||||||
{/* Header */}
|
{/* 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>
|
<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">
|
<div className="flex flex-wrap justify-center gap-3 text-sm text-text/70">
|
||||||
<span>{contactInfo.location}</span>
|
<span>{contactInfo.location}</span>
|
||||||
<span className="hidden sm:inline text-primary">•</span>
|
{contactInfo.website && (
|
||||||
<a href={`tel:${contactInfo.phone.replace(/\./g, "")}`} className="hover:text-primary anim-base">
|
<>
|
||||||
{contactInfo.phone}
|
<span className="hidden sm:inline text-primary">•</span>
|
||||||
</a>
|
<a href={contactInfo.website} className="hover:text-primary anim-base">
|
||||||
<span className="hidden sm:inline text-primary">•</span>
|
{new URL(contactInfo.website).hostname}
|
||||||
<a href={`mailto:${contactInfo.email}`} className="hover:text-primary anim-base">
|
</a>
|
||||||
{contactInfo.email}
|
</>
|
||||||
</a>
|
)}
|
||||||
|
{contactInfo.linkedin && (
|
||||||
|
<>
|
||||||
|
<span className="hidden sm:inline text-primary">•</span>
|
||||||
|
<a href={contactInfo.linkedin} className="hover:text-primary anim-base">
|
||||||
|
LinkedIn
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -101,18 +59,47 @@ export function Resume() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Accomplishments */}
|
{/* Certifications */}
|
||||||
<section className="mb-10 anim-fade-in">
|
{certifications && certifications.length > 0 && (
|
||||||
<SectionTitle>Professional Accomplishments</SectionTitle>
|
<section className="mb-10 anim-fade-in">
|
||||||
<ul className="space-y-3">
|
<SectionTitle>Certifications</SectionTitle>
|
||||||
{accomplishments.map((item, i) => (
|
<ul className="space-y-3">
|
||||||
<li key={i} className="flex gap-3 text-text/85">
|
{certifications.map((item, i) => (
|
||||||
<span className="mt-2 h-2 w-2 shrink-0 rounded-full bg-primary" />
|
<li key={i} className="flex gap-3 text-text/85">
|
||||||
<span>{item}</span>
|
<span className="mt-2 h-2 w-2 shrink-0 rounded-full bg-primary" />
|
||||||
</li>
|
<span>{item}</span>
|
||||||
))}
|
</li>
|
||||||
</ul>
|
))}
|
||||||
</section>
|
</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 */}
|
{/* Work History */}
|
||||||
<section className="mb-10 anim-fade-in">
|
<section className="mb-10 anim-fade-in">
|
||||||
@@ -121,15 +108,27 @@ export function Resume() {
|
|||||||
{workHistory.map((job, i) => (
|
{workHistory.map((job, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
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>
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||||
<h4 className="font-semibold text-text">{job.title}</h4>
|
<div>
|
||||||
<p className="text-text/70 text-sm">
|
<h4 className="font-semibold text-text">{job.title}</h4>
|
||||||
{job.company} — {job.location}
|
<p className="text-text/70 text-sm">
|
||||||
</p>
|
{job.company} — {job.location}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="mt-2 sm:mt-0 text-sm text-primary font-medium">{job.dates}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="mt-2 sm:mt-0 text-sm text-primary font-medium">{job.dates}</span>
|
{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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -146,7 +145,10 @@ export function Resume() {
|
|||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-semibold text-text">{edu.degree}</h4>
|
<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>
|
</div>
|
||||||
<span className="mt-2 sm:mt-0 text-sm text-primary font-medium">{edu.date}</span>
|
<span className="mt-2 sm:mt-0 text-sm text-primary font-medium">{edu.date}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
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 (
|
return (
|
||||||
<section id={id} className="scroll-mt-24">
|
<section id={id} className="scroll-mt-24">
|
||||||
{children}
|
{children}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// ThemeToggle.tsx
|
// ThemeToggle.tsx
|
||||||
import React from "react";
|
import { useState } from "react";
|
||||||
import { useTheme } from "../hooks/useTheme";
|
import { useTheme } from "../hooks/useTheme";
|
||||||
|
|
||||||
// Actual primary colors for each theme
|
// 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 }) {
|
export function ThemeToggle({ compact = false }: { compact?: boolean }) {
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
const themes = ["a", "b", "c", "d", "e"] as const;
|
const themes = ["a", "b", "c", "d", "e"] as const;
|
||||||
|
|
||||||
const crossfadeTo = (next: typeof themes[number]) => {
|
const crossfadeTo = (next: typeof themes[number]) => {
|
||||||
@@ -26,7 +27,8 @@ export function ThemeToggle({ compact = false }: { compact?: boolean }) {
|
|||||||
root.classList.add("hero-xfade");
|
root.classList.add("hero-xfade");
|
||||||
|
|
||||||
// 3) switch theme (your existing logic)
|
// 3) switch theme (your existing logic)
|
||||||
setTheme(next as any);
|
setTheme(next);
|
||||||
|
setOpen(false);
|
||||||
|
|
||||||
// 4) remove crossfade flag after the animation
|
// 4) remove crossfade flag after the animation
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
@@ -37,42 +39,46 @@ export function ThemeToggle({ compact = false }: { compact?: boolean }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative inline-block text-text">
|
<div className="relative inline-block text-text">
|
||||||
<details className="group">
|
<button
|
||||||
<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">
|
type="button"
|
||||||
<span className="font-medium">{compact ? "Theme" : "Toggle Theme"}</span>
|
aria-expanded={open}
|
||||||
<span aria-hidden>▾</span>
|
aria-haspopup="listbox"
|
||||||
</summary>
|
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>{open ? "▴" : "▾"}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="
|
className={`
|
||||||
absolute top-full mt-2 z-[70] rounded-lg border border-secondary bg-bg/95 p-2 shadow-xl backdrop-blur
|
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]
|
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
|
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]
|
origin-top transition-[opacity,transform] duration-200 ease-out
|
||||||
pointer-events-none transition-all duration-300 ease-out
|
${open ? "opacity-100 scale-100 translate-y-0 pointer-events-auto" : "opacity-0 scale-95 -translate-y-1 pointer-events-none"}
|
||||||
group-open:opacity-100 group-open:scale-y-100 group-open:translate-y-0 group-open:pointer-events-auto
|
`}
|
||||||
"
|
>
|
||||||
>
|
<ul className="space-y-1" role="listbox">
|
||||||
<ul className="space-y-1">
|
{themes.map((t) => (
|
||||||
{themes.map((t) => (
|
<li key={t}>
|
||||||
<li key={t}>
|
<button
|
||||||
<button
|
type="button"
|
||||||
onClick={() => crossfadeTo(t)}
|
onClick={() => crossfadeTo(t)}
|
||||||
className={`w-full rounded px-3 py-2 text-left hover:bg-secondary/60 anim-base ${
|
className={`w-full rounded px-3 py-2 text-left hover:bg-secondary/60 anim-base ${
|
||||||
theme === t ? "outline outline-1 outline-primary" : ""
|
theme === t ? "outline outline-1 outline-primary" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="mr-2 inline-block h-3 w-3 rounded-full align-middle"
|
className="mr-2 inline-block h-3 w-3 rounded-full align-middle"
|
||||||
style={{ background: themeColors[t].primary }}
|
style={{ background: themeColors[t].primary }}
|
||||||
/>
|
/>
|
||||||
{themeColors[t].label}
|
{themeColors[t].label}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
77
src/data/resume.ts
Normal file
77
src/data/resume.ts
Normal 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
30
src/types/turnstile.d.ts
vendored
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user