Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb85b0d834 | ||
|
|
8f86e13dfc |
@@ -1,2 +0,0 @@
|
||||
VITE_CONTACT_API_URL=/api/contact
|
||||
VITE_TURNSTILE_SITE_KEY=0x4AAAAAACfQya1R13nGeuOy
|
||||
@@ -1,64 +0,0 @@
|
||||
name: Deploy Jody's App
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
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: Build Frontend
|
||||
run: npm run build
|
||||
|
||||
- name: Install Contact API Dependencies
|
||||
run: npm ci --prefix contact-api
|
||||
|
||||
- name: Build Contact API
|
||||
run: npm run build --prefix contact-api
|
||||
|
||||
- name: Sync Frontend Files
|
||||
run: |
|
||||
mkdir -p /var/www/jody/dist
|
||||
rm -rf /var/www/jody/dist/*
|
||||
cp -r dist/* /var/www/jody/dist/
|
||||
|
||||
- name: Sync Contact API Runtime Files
|
||||
run: |
|
||||
mkdir -p /var/www/jody/contact-api
|
||||
rm -rf /var/www/jody/contact-api/dist
|
||||
cp -r contact-api/dist /var/www/jody/contact-api/
|
||||
cp contact-api/package.json /var/www/jody/contact-api/
|
||||
cp contact-api/package-lock.json /var/www/jody/contact-api/
|
||||
|
||||
- name: Install Contact API Production Dependencies
|
||||
run: |
|
||||
cd /var/www/jody/contact-api
|
||||
npm ci --omit=dev
|
||||
|
||||
- name: Restart Contact API
|
||||
run: |
|
||||
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"
|
||||
@@ -1,43 +1,32 @@
|
||||
# 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.yaml
|
||||
@@ -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.
|
||||
@@ -11,7 +11,7 @@ This site demonstrates my personality, skills, and presents the tone I provide t
|
||||
|
||||
**STACK**
|
||||
|
||||
- SPA with React (functional components, hooks)
|
||||
-SPA with React (functonal components, hooks)
|
||||
-TailwindCSS (custom tokens and theme system)
|
||||
-Theme Engine (5 themes with automated color adaption per user preference )
|
||||
-IntersectionObserver (based scroll nav)
|
||||
@@ -36,20 +36,36 @@ src/
|
||||
|
||||
## Theme System
|
||||
|
||||
### within index:
|
||||
#### 5 unique themes are laid out by:
|
||||
- color-bg
|
||||
- color-primary
|
||||
- color-text
|
||||
- color-secondary
|
||||
- color-tertiary
|
||||
within index:
|
||||
# -5 unique themes are layed out by:
|
||||
# *color-bg
|
||||
# *color-primary
|
||||
# *color-text
|
||||
# *color-secondary
|
||||
# *color-tertiary
|
||||
|
||||
These themes are applied via html[data-theme="x"] and are used across the site for all gradients, tints, accents, etc.
|
||||
|
||||
|
||||
## DEV NOTES as of 10/29/2025
|
||||
|
||||
-All social links are dummy values as of now.
|
||||
-Section layout is controlleted via <Section id="...."></Section> wrappers.
|
||||
-Images are outdated and will be replaced.
|
||||
-UI is mobile-oriented, but device friendly.
|
||||
|
||||
|
||||
## To run on local client:
|
||||
## TODO
|
||||
|
||||
-Add links to projects within cards
|
||||
-Change out experiance tab for resume/skills
|
||||
-Add animations
|
||||
-more ways to contact
|
||||
-Deploy site via personal service
|
||||
|
||||
|
||||
## View Progress
|
||||
|
||||
```bash
|
||||
npm i
|
||||
|
||||
@@ -59,38 +75,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)
|
||||
Jody Holt
|
||||
Frontend Developer • Passion Pioneer
|
||||
[GitHub](https://github.com/Ricearoni1245) • [LinkedIn](https://www.linkedin.com/in/jody-holt-9b19b0256) • [Email](mailto:jholt1008@gmail.com)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -2,17 +2,12 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/jh-logo.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Jody Holt</title>
|
||||
<title>jody-holt-portfolio</title>
|
||||
</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;
|
||||
}
|
||||
@@ -19,7 +19,6 @@
|
||||
"@types/react": "^19.1.16",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"baseline-browser-mapping": "^2.9.11",
|
||||
"eslint": "^9.36.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.22",
|
||||
@@ -2020,9 +2019,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.9.11",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz",
|
||||
"integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==",
|
||||
"version": "2.8.17",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.17.tgz",
|
||||
"integrity": "sha512-j5zJcx6golJYTG6c05LUZ3Z8Gi+M62zRT/ycz4Xq4iCOdpcxwg7ngEYD4KA0eWZC7U17qh/Smq8bYbACJ0ipBA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
@@ -23,7 +21,6 @@
|
||||
"@types/react": "^19.1.16",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"baseline-browser-mapping": "^2.9.11",
|
||||
"eslint": "^9.36.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.22",
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" role="img" aria-label="JH logo">
|
||||
<rect width="64" height="64" rx="12" fill="#111827" />
|
||||
<text
|
||||
x="50%"
|
||||
y="57%"
|
||||
text-anchor="middle"
|
||||
font-family="Arial, sans-serif"
|
||||
font-size="28"
|
||||
font-weight="700"
|
||||
letter-spacing="2"
|
||||
fill="#F9FAFB"
|
||||
>
|
||||
JH
|
||||
</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 377 B |
@@ -1,16 +1,12 @@
|
||||
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";
|
||||
|
||||
import { Projects } from "./components/Projects";
|
||||
import { Resume } from "./components/Resume";
|
||||
import { Placeholder } from "./components/Placeholder";
|
||||
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", "projects", "experience"], []);
|
||||
const refs = useRef<Record<string, HTMLElement | null>>({});
|
||||
const [active, setActive] = useState<string>(sections[0]);
|
||||
|
||||
@@ -47,17 +43,12 @@ return (
|
||||
<Navbar onNav={handleNav} />
|
||||
|
||||
|
||||
|
||||
<main>
|
||||
<Section id="home"><Hero /></Section>
|
||||
<GradientBand />
|
||||
<Section id="about"><AboutMe /></Section>
|
||||
<Section id="projects"><Placeholder title="Projects" /></Section>
|
||||
<GradientBand />
|
||||
<Section id="projects"><Projects /></Section>
|
||||
<GradientBand />
|
||||
<Section id="experience"><Resume /></Section>
|
||||
<GradientBand />
|
||||
<Section id="contact"><Contact /></Section>
|
||||
<Section id="experience"><Placeholder title="Experience" /></Section>
|
||||
</main>
|
||||
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 444 KiB After Width: | Height: | Size: 444 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 375 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 964 B |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 35 KiB |
@@ -1,41 +0,0 @@
|
||||
import aboutImg from "../assets/img/about-img.png";
|
||||
|
||||
export function AboutMe() {
|
||||
return (
|
||||
<section className="mx-auto max-w-5xl px-4 py-16 md:py-24 anim-fade-in">
|
||||
<h2 className="text-3xl md:text-4xl font-extrabold text-text mb-10 font-title text-center">About Me</h2>
|
||||
|
||||
<div className="md:flex md:gap-10 md:items-start">
|
||||
{/* Text content */}
|
||||
<div className="md:flex-1">
|
||||
<div className="mb-10 p-6 rounded-xl bg-secondary/20 border border-secondary shadow-md md:bg-transparent md:border-0 md:shadow-none md:p-0">
|
||||
<h3 className="text-xl font-bold text-primary mb-3 font-title">Background</h3>
|
||||
<p className="text-text/85 leading-relaxed">
|
||||
Growing up in a small Texas town, I learned the value of living simply and appreciating what matters most. I was blessed with a loving, supportive family who encouraged my passions, no matter how ambitious. Early on, I developed a strong sense of right and wrong and felt a calling to help others. For me, doing the right thing isn't just about being a good person—it's a core part of my identity. Inspired by the teachings of Christ, I've always embraced leadership roles and sought opportunities to serve wherever I'm needed.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 rounded-xl bg-secondary/10 border border-secondary shadow-md md:bg-transparent md:border-0 md:shadow-none md:p-0">
|
||||
<h3 className="text-xl font-bold text-primary mb-3 font-title">My Strive</h3>
|
||||
<p className="text-text/85 leading-relaxed">
|
||||
As I continue my programming journey, I strive each day to expand my knowledge and skills. I have a deep passion for software development and a unique talent for designing user interfaces. I love collaborating with others to push boundaries and create innovative projects. My long-term goal is to offer consulting services using independent platforms, ensuring greater security and data protection for my family, friends, and clients. My drive for innovation motivates me to explore the latest technologies and deliver efficient, high-quality solutions. Whether working solo or as part of a team, I am committed to producing content that exceeds expectations.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image with caption - below text on mobile, right side on desktop */}
|
||||
<figure className="mt-8 md:mt-0 md:w-80 lg:w-96 md:flex-shrink-0">
|
||||
<div className="overflow-hidden rounded-2xl border border-secondary shadow-lg md:border-0 md:shadow-none">
|
||||
<img
|
||||
src={aboutImg}
|
||||
alt="Me and my loving girlfriend"
|
||||
className="w-full h-auto object-cover hover:scale-105 anim-base"
|
||||
/>
|
||||
</div>
|
||||
<figcaption className="mt-3 text-center text-sm text-text/60 italic">
|
||||
Me and my loving girlfriend
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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,18 +1,14 @@
|
||||
import githubIcon from "../assets/img/github-icon.png";
|
||||
import linkedInIcon from "../assets/img/linkedin-icon.png";
|
||||
import facebookIcon from "../assets/img/facebook-icon.png";
|
||||
import React from "react";
|
||||
|
||||
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 },
|
||||
];
|
||||
|
||||
type Social = { label: string; href: string; icon?: string };
|
||||
type Social = { label: string; href: string; icon?: React.ReactNode };
|
||||
|
||||
export function Footer({
|
||||
year = new Date().getFullYear(),
|
||||
socials = defaultSocials,
|
||||
socials = [
|
||||
{ label: "GitHub", href: "#" },
|
||||
{ label: "LinkedIn", href: "#" },
|
||||
{ label: "Email", href: "#" },
|
||||
],
|
||||
showBackToTop = true,
|
||||
}: {
|
||||
year?: number;
|
||||
@@ -21,68 +17,39 @@ export function Footer({
|
||||
}) {
|
||||
return (
|
||||
<footer className="border-t border-secondary bg-bg px-4 py-10">
|
||||
<div className="mx-auto max-w-7xl flex flex-col gap-6 md:grid md:grid-cols-3 md:items-end">
|
||||
<div className="mx-auto flex max-w-7xl flex-col items-center justify-between gap-6 md:flex-row">
|
||||
{/* Left: Brand + tagline */}
|
||||
<div className="text-center md:text-left">
|
||||
<div className="text-xl md:text-2xl font-extrabold font-name tracking-wide text-text">
|
||||
Jody Holt
|
||||
</div>
|
||||
<div className="text-[11px] md:text-sm text-text/70">
|
||||
Passion Pioneer
|
||||
</div>
|
||||
<div className="text-xl font-extrabold tracking-wide text-text">Jody Holt</div>
|
||||
<p className="text-sm text-text/70">Design • Develop • Deliver</p>
|
||||
</div>
|
||||
|
||||
<nav className="flex items-center gap-5 md:justify-center">
|
||||
<button
|
||||
className="text-text hover:text-primary anim-base"
|
||||
onClick={() => document.getElementById("home")?.scrollIntoView({ behavior: "smooth" })}
|
||||
>
|
||||
Background
|
||||
</button>
|
||||
<button
|
||||
className="text-text hover:text-primary anim-base"
|
||||
onClick={() => document.getElementById("projects")?.scrollIntoView({ behavior: "smooth" })}
|
||||
>
|
||||
Projects
|
||||
</button>
|
||||
<button
|
||||
className="text-text hover:text-primary anim-base"
|
||||
onClick={() => document.getElementById("experience")?.scrollIntoView({ behavior: "smooth" })}
|
||||
>
|
||||
Experience
|
||||
</button>
|
||||
<button
|
||||
className="text-text hover:text-primary anim-base"
|
||||
onClick={() => document.getElementById("contact")?.scrollIntoView({ behavior: "smooth" })}
|
||||
>
|
||||
Contact
|
||||
</button>
|
||||
{/* Middle: Links */}
|
||||
<nav className="flex items-center gap-5">
|
||||
<a className="text-text hover:text-primary" href="#projects">Projects</a>
|
||||
<a className="text-text hover:text-primary" href="#experience">Experience</a>
|
||||
<a className="text-text hover:text-primary" href="#home">Background</a>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-4 text-text md:justify-end">
|
||||
{/* Right: Socials */}
|
||||
<div className="flex items-center gap-4 text-text">
|
||||
{socials.map((s) => (
|
||||
<a
|
||||
key={s.label}
|
||||
href={s.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={s.label}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-lg border border-secondary hover:border-primary hover:text-primary anim-base"
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-lg border border-secondary hover:border-primary hover:text-primary"
|
||||
title={s.label}
|
||||
>
|
||||
{s.icon ? (
|
||||
<img src={s.icon} alt={s.label} className="h-5 w-5 invert brightness-0 invert opacity-80" />
|
||||
) : (
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-current" />
|
||||
)}
|
||||
{/* replace with real SVGs later */}
|
||||
{s.icon ?? <span className="h-2.5 w-2.5 rounded-full bg-current" />}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto mt-6 flex max-w-7xl items-center justify-center gap-4">
|
||||
<div className="text-center text-xs text-text/60">
|
||||
© {year} Jody Holt • All rights reserved
|
||||
</div>
|
||||
<div className="text-center text-xs text-text/60">© {year} Jody Holt • All rights reserved</div>
|
||||
{showBackToTop && (
|
||||
<button
|
||||
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
|
||||
@@ -92,17 +59,6 @@ export function Footer({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mx-auto mt-4 max-w-7xl text-center text-[10px] text-text/40">
|
||||
Icons by{" "}
|
||||
<a
|
||||
href="https://icons8.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-primary anim-base underline"
|
||||
>
|
||||
Icons8
|
||||
</a>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -1,150 +1,156 @@
|
||||
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 facebookIcon from "../assets/img/facebook-icon.png";
|
||||
import React from "react";
|
||||
import profileImage from "../assets/jody.png";
|
||||
import jodyMobile from "../assets/Jody-mobile.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 },
|
||||
];
|
||||
export function Hero() {
|
||||
const { theme } = useTheme(); // "a" | "b" | "c" | "d" | "e"
|
||||
return (
|
||||
<section key={theme} className="relative w-full bg-hero anim-fade-in">
|
||||
<section className="relative w-full bg-hero">
|
||||
<div className="md:hidden flex flex-col items-center text-center gap-2 min-h-[calc(100vh-64px)] py-6">
|
||||
<h1
|
||||
className="font-extrabold font-title tracking-wide leading-tight text-text
|
||||
text-2xl underline md:decoration-secondary decoration-primary anim-pop-in"
|
||||
className="font-extrabold tracking-wide leading-tight text-text
|
||||
text-2xl underline md:decoration-secondary decoration-primary"
|
||||
>
|
||||
Design. Develop. Deliver.
|
||||
</h1>
|
||||
|
||||
<p className=" font-main text-sm text-text/80 anim-fade-in">
|
||||
<p className="text-sm text-text/80">
|
||||
Driven by a genuine passion for creation through code.
|
||||
</p>
|
||||
|
||||
<div className="relative h-48 w-48 rounded-full overflow-hidden mb-2 anim-pop-in float-idle">
|
||||
<div className="relative h-68 w-68 rounded-full overflow-hidden mb-2">
|
||||
|
||||
<div className="absolute inset-0 rounded-full img-glow" />
|
||||
|
||||
|
||||
<img
|
||||
src={jodyMobile}
|
||||
alt="Jody Holt"
|
||||
className="relative z-[1] h-full w-full object-cover select-none pointer-events-none will-change-transform"
|
||||
className="relative z-[1] h-full w-full object-cover select-none pointer-events-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 className="mt- font-extrabold font-title text-text leading-tight tracking-wide text-3xl anim-fade-in">
|
||||
<h2 className="mt- font-extrabold text-text leading-tight tracking-wide text-3xl">
|
||||
Hello, I’m Jody Holt
|
||||
</h2>
|
||||
|
||||
<p className=" font-main mt-2 text-[22px] text-base text-text/85 anim-fade-in">
|
||||
<p className=" mt-5 text-lg text-base text-text/85">
|
||||
Turning concepts into clean, functional code.
|
||||
</p>
|
||||
|
||||
<p className="text-2xl font-semibold text-text mt-4">It’s What I Do.</p>
|
||||
|
||||
<p className="mt-8 text-2xl text-text">I would love to connect!</p>
|
||||
|
||||
|
||||
<div className="mt-5 mb-4 flex items-center justify-center gap-4">
|
||||
{socialLinks.map((a) => (
|
||||
<div className="mt-2 mb-4 flex items-center justify-center gap-4">
|
||||
{[
|
||||
{ label: "GitHub", href: "#" },
|
||||
{ label: "LinkedIn", href: "#" },
|
||||
{ label: "Email", href: "#" },
|
||||
].map((a) => (
|
||||
<a
|
||||
key={a.label}
|
||||
href={a.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={a.label}
|
||||
className="inline-flex h-12 w-12 items-center justify-center rounded-lg
|
||||
border border-secondary/70 bg-secondary/20 text-text anim-base icon-hover
|
||||
hover:border-primary hover:text-primary focus:outline-none focus-visible:ring-2
|
||||
focus-visible:ring-primary/60"
|
||||
className="inline-flex h-12 w-12 items-center justify-center rounded-lg border border-secondary/70 bg-secondary/20 text-text hover:border-primary hover:text-primary transition"
|
||||
>
|
||||
<img src={a.icon} alt={a.label} className="h-6 w-6 invert brightness-0 invert opacity-90" />
|
||||
<span className="h-3 w-3 rounded-full bg-current" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/*DESKTOP*/}
|
||||
{/*____________________________________________________________________________________________________*/}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div className="hidden md:block md:mx-auto max-w-7xl px-4">
|
||||
<div
|
||||
className=" md:flex md:flex-col items-center hidden md: md:mx-auto px-4 w-full sm:h-[calc(100vh-80px)]
|
||||
overflow-hidden"
|
||||
className="
|
||||
min-h-[calc(100vh-64px)]
|
||||
md:min-h-[calc(100vh-80px)]
|
||||
flex flex-col md:flex-row items-start gap-10 lg:gap-10
|
||||
py-8 md:py-1
|
||||
"
|
||||
>
|
||||
<div className="shrink-0 self-start lg:pl-20">
|
||||
<img
|
||||
src={profileImage}
|
||||
alt="Jody Holt"
|
||||
className="w-[240px] sm:h-[280px] md:h-[700px] lg:h-[780px] xl:g-[800px] h-auto object-contain select-none pointer-events-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 self-start md:pt-10 items-center text-center">
|
||||
<h1
|
||||
className="text-text font-extrabold tracking-wide leading-tight
|
||||
text-3xl sm:text-4xl md:text-3xl lg:text-5xl xl:text-6xl
|
||||
underline md:decoration-secondary decoration-primary font-title
|
||||
text-center lg:pt-2 xl:pt-7 anim-pop-in "
|
||||
text-3xl sm:text-4xl md:text-3xl lg:text-5xl xl:text-6xl underline md:decoration-secondary decoration-primary"
|
||||
>
|
||||
Design. Develop. Deliver.
|
||||
</h1>
|
||||
|
||||
<p
|
||||
className="text-text/80 text-sm sm:text-base md:text-lg lg:text-xl xl:text-2xl
|
||||
font-main text-center anim-fade-in"
|
||||
className="mb-10 text-text/80
|
||||
text-sm sm:text-base md:text-lg lg:text-xl xl:text-2"
|
||||
>
|
||||
Driven by a genuine passion for creation through code.
|
||||
</p>
|
||||
|
||||
<div
|
||||
className="
|
||||
flex items-center justify-evenly
|
||||
w-full max-h-[calc(auto-400px) lg:px-5 xl:px-20"
|
||||
>
|
||||
<div className="self-end flex flex-col items-start lg:gap-1 mb-8 lg: items-center
|
||||
lg:pb-22 2xl:pb-30 ">
|
||||
<h5
|
||||
className="font-semi-bold text-text
|
||||
text-2xl sm:text-3xl md:text-2xl lg:text-2xl xl:text-4xl
|
||||
font-title text-left"
|
||||
>
|
||||
Hello,
|
||||
</h5>
|
||||
<h2
|
||||
className="font-extrabold text-text leading-tight tracking-wide
|
||||
text-2xl sm:text-3xl md:text-3xl lg:text-5xl xl:text-8xl
|
||||
font-title text-left"
|
||||
className="font-extrabold text-text leading-tight tracking-wide mb-5
|
||||
text-2xl sm:text-3xl md:text-4xl lg:text-5xl xl:text-6xl"
|
||||
>
|
||||
I’m Jody Holt
|
||||
Hello, I’m Jody Holt
|
||||
</h2>
|
||||
|
||||
<p
|
||||
className="mb-3 text-text/85 md:mt-2
|
||||
text-base md:text-xl lg:text-2xl xl:text-4xl font-main"
|
||||
className="mb-3 text-text/85 md:mt-10 md:mb-5
|
||||
text-base md:text-xl lg:text-2xl xl:text-3xl"
|
||||
>
|
||||
Turning concepts into clean, functional code.
|
||||
</p>
|
||||
|
||||
<p
|
||||
className="mb-30 text-text/85
|
||||
text-base md:text-3xl lg:text-4xl xl:text-5xl
|
||||
font-semibold"
|
||||
>
|
||||
It’s What I Do.
|
||||
</p>
|
||||
|
||||
<div className="flex self-start items-center justify-start gap-4 md:gap-6 mt-5">
|
||||
{socialLinks.map((a) => (
|
||||
<p
|
||||
className="mb-8 text-text
|
||||
text-lg md:text-4xl lg:text-5xl"
|
||||
>
|
||||
I would love to connect!
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-center gap-4 md:gap-6">
|
||||
{[
|
||||
{ label: "GitHub", href: "#" },
|
||||
{ label: "LinkedIn", href: "#" },
|
||||
{ label: "Email", href: "#" },
|
||||
].map((a) => (
|
||||
<a
|
||||
key={a.label}
|
||||
href={a.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center justify-center rounded-xl border
|
||||
border-secondary/70 bg-secondary/20 text-text transition h-10 w-10
|
||||
sm:h-12 sm:w-12 md:h-14 md:w-14 lg:h-16 lg:w-16 hover:border-primary
|
||||
hover:text-primary anim-base icon-hover focus:outline-none
|
||||
focus-visible:ring-2 focus-visible:ring-primary/60"
|
||||
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 hover:text-primary"
|
||||
aria-label={a.label}
|
||||
title={a.label}
|
||||
>
|
||||
<img src={a.icon} alt={a.label} className="h-6 w-6 md:h-8 md:w-8 lg:h-10 lg:w-10 invert brightness-0 invert opacity-90" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-current" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className=" justify-start ">
|
||||
<img
|
||||
src={profileImage}
|
||||
alt="Jody Holt"
|
||||
className="lg:max-h-[78vh] sm:max-h-[50vh] h-auto object-contain select-none
|
||||
pointer-events-none anim-pop-in will-change-transform"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { useState } from "react";
|
||||
import React, { useState } from "react";
|
||||
import { ThemeToggle } from "./ThemeToggle";
|
||||
|
||||
export function Navbar({ onNav }: { onNav: (id: string) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const links = [
|
||||
{ id: "home", label: "Intro" },
|
||||
{ id: "about", label: "About" },
|
||||
{ id: "home", label: "Background" },
|
||||
{ id: "projects", label: "Projects" },
|
||||
{ id: "experience", label: "Resume" },
|
||||
{ id: "contact", label: "Contact" },
|
||||
{ id: "experience", label: "Experience" },
|
||||
];
|
||||
|
||||
const handleNav = (id: string) => {
|
||||
@@ -17,20 +15,12 @@ export function Navbar({ onNav }: { onNav: (id: string) => void }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<header
|
||||
className="sticky top-0 z-50 border-b border-secondary bg-bg/90
|
||||
backdrop-blur h-16 md:h-20 font-main w-full anim-fade-in"
|
||||
>
|
||||
<div
|
||||
className="flex h-full w-full items-center justify-between px-4 sm:px-6
|
||||
md:px-10 lg:px-16"
|
||||
>
|
||||
<div className="flex items-center gap-3 hover-pop anim-base select-none">
|
||||
<header className="sticky top-0 z-50 border-b border-secondary bg-bg/90 backdrop-blur h-16 md:h-20">
|
||||
<div className="mx-auto flex h-full max-w-7xl items-center justify-between px-4">
|
||||
{/* Brand (stacked) */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="leading-tight">
|
||||
<div
|
||||
className="text-xl md:text-2xl font-extrabold font-name tracking-wide
|
||||
text-text"
|
||||
>
|
||||
<div className="text-xl md:text-2xl font-extrabold tracking-wide text-text">
|
||||
Jody Holt
|
||||
</div>
|
||||
<div className="text-[11px] md:text-sm text-text/70">
|
||||
@@ -39,11 +29,12 @@ export function Navbar({ onNav }: { onNav: (id: string) => void }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop nav */}
|
||||
<nav className="hidden items-center gap-6 md:flex">
|
||||
{links.map((l) => (
|
||||
<button
|
||||
key={l.id}
|
||||
className="text-text/90 hover:text-primary anim-base hover:-translate-y-[1px] focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/60 rounded"
|
||||
className="text-text hover:text-primary"
|
||||
onClick={() => handleNav(l.id)}
|
||||
>
|
||||
{l.label}
|
||||
@@ -52,6 +43,7 @@ export function Navbar({ onNav }: { onNav: (id: string) => void }) {
|
||||
<ThemeToggle />
|
||||
</nav>
|
||||
|
||||
{/* Mobile controls */}
|
||||
<div className="md:hidden">
|
||||
<button
|
||||
aria-expanded={open}
|
||||
@@ -71,25 +63,25 @@ export function Navbar({ onNav }: { onNav: (id: string) => void }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile dropdown */}
|
||||
{/* Mobile dropdown */}
|
||||
<div
|
||||
className={`md:hidden transition-[max-height,opacity,transform] duration-300 ease-out ${
|
||||
open
|
||||
? "max-h-96 overflow-visible opacity-100 translate-y-0"
|
||||
: "max-h-0 overflow-hidden opacity-0 -translate-y-2 pointer-events-none"
|
||||
className={`md:hidden transition-[max-height] duration-300 ${
|
||||
open ? "max-h-96 overflow-visible" : "max-h-0 overflow-hidden"
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-2 border-t border-secondary bg-bg px-4 py-3">
|
||||
{links.map((l) => (
|
||||
<button
|
||||
key={l.id}
|
||||
className="block w-full rounded px-3 py-2 text-left text-text hover:bg-secondary/60 hover:text-primary anim-base"
|
||||
className="block w-full rounded px-3 py-2 text-left text-text hover:bg-secondary/60"
|
||||
onClick={() => handleNav(l.id)}
|
||||
>
|
||||
{l.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="pt-2">
|
||||
<ThemeToggle compact />{" "}
|
||||
<ThemeToggle compact />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// =====================================
|
||||
import React from "react";
|
||||
|
||||
|
||||
export function Placeholder({ title }: { title: string }) {
|
||||
|
||||
@@ -1,371 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import skymoneycover from "../assets/img/Skymoney-cover-img.jpg";
|
||||
import skymoneycoverMobile from "../assets/img/skymoney-mobile-cover-img.jpg";
|
||||
import millercover from "../assets/img/500nmain-cover-img.jpg";
|
||||
import millercoverMobile from "../assets/img/500nmain-mobile-cover-img.jpg";
|
||||
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;
|
||||
description: string;
|
||||
coverImage: string;
|
||||
coverImageMobile: string;
|
||||
video: string;
|
||||
videoMobile: string;
|
||||
techStack: string[];
|
||||
liveUrl?: string;
|
||||
beta?: boolean;
|
||||
inDev?: boolean;
|
||||
note?: string;
|
||||
comingSoon?: boolean;
|
||||
};
|
||||
|
||||
const projects: Project[] = [
|
||||
{
|
||||
id: "skymoney",
|
||||
title: "SkyMoney",
|
||||
description:
|
||||
"A budgeting app that simulates your bank account to ensure financial discipline.",
|
||||
coverImage: skymoneycover,
|
||||
coverImageMobile: skymoneycoverMobile,
|
||||
video: skymoneyvideo,
|
||||
videoMobile: skymoneyvideoMobile,
|
||||
techStack: ["React", "TypeScript", "Node.js", "PostgreSQL"],
|
||||
liveUrl: "https://skymoneybudget.com",
|
||||
beta: true,
|
||||
inDev: true,
|
||||
note: "Contact Jody for beta access.",
|
||||
},
|
||||
{
|
||||
id: "miller-building",
|
||||
title: "Miller Building Website",
|
||||
description:
|
||||
"A website showcasing the historic Miller Building located in Borger, Texas.",
|
||||
coverImage: millercover,
|
||||
coverImageMobile: millercoverMobile,
|
||||
video: millervideo,
|
||||
videoMobile: millervideoMobile,
|
||||
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({
|
||||
isOpen,
|
||||
onClose,
|
||||
video,
|
||||
videoMobile,
|
||||
title,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
video: string;
|
||||
videoMobile: string;
|
||||
title: string;
|
||||
}) {
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => setIsMobile(window.innerWidth < 768);
|
||||
checkMobile();
|
||||
window.addEventListener("resize", checkMobile);
|
||||
return () => window.removeEventListener("resize", checkMobile);
|
||||
}, []);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm anim-fade-in"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="relative w-full max-w-4xl mx-4 bg-bg rounded-2xl overflow-hidden shadow-2xl border border-secondary"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-secondary">
|
||||
<h3 className="text-xl font-bold text-text">{title} Demo</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg hover:bg-secondary/50 text-text/70 hover:text-text anim-base"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className={isMobile ? "aspect-[9/16] bg-black" : "aspect-video bg-black"}>
|
||||
<video
|
||||
src={isMobile ? videoMobile : video}
|
||||
controls
|
||||
autoPlay
|
||||
muted
|
||||
className="w-full h-full object-contain"
|
||||
>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectCard({
|
||||
project,
|
||||
onPlayVideo,
|
||||
}: {
|
||||
project: Project;
|
||||
onPlayVideo: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="group relative rounded-2xl border border-secondary bg-secondary/20 overflow-hidden hover:border-primary/50 anim-base hover-pop">
|
||||
{/* Cover Image - Desktop */}
|
||||
<div className="relative aspect-video overflow-hidden hidden md:block">
|
||||
<img
|
||||
src={project.coverImage}
|
||||
alt={project.title}
|
||||
className="w-full h-full object-cover group-hover:scale-105 anim-base"
|
||||
/>
|
||||
{/* Play Button Overlay */}
|
||||
<button
|
||||
onClick={onPlayVideo}
|
||||
className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover:opacity-100 anim-base"
|
||||
aria-label={`Play ${project.title} demo video`}
|
||||
>
|
||||
<div className="flex items-center justify-center w-16 h-16 rounded-full bg-primary/90 text-white hover:bg-primary hover:scale-110 anim-base">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
{/* Beta Badge */}
|
||||
{project.beta && (
|
||||
<div className="absolute top-3 right-3 px-3 py-1 rounded-full bg-primary text-white text-xs font-bold uppercase tracking-wider">
|
||||
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>
|
||||
|
||||
{/* Cover Image - Mobile */}
|
||||
<div className="relative aspect-[9/16] overflow-hidden md:hidden mx-4 my-4 rounded-xl">
|
||||
<img
|
||||
src={project.coverImageMobile}
|
||||
alt={project.title}
|
||||
className="w-full h-full object-cover group-hover:scale-105 anim-base"
|
||||
/>
|
||||
{/* Play Button Overlay */}
|
||||
<button
|
||||
onClick={onPlayVideo}
|
||||
className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover:opacity-100 anim-base"
|
||||
aria-label={`Play ${project.title} demo video`}
|
||||
>
|
||||
<div className="flex items-center justify-center w-16 h-16 rounded-full bg-primary/90 text-white hover:bg-primary hover:scale-110 anim-base">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
{/* Beta Badge */}
|
||||
{project.beta && (
|
||||
<div className="absolute top-3 right-3 px-3 py-1 rounded-full bg-primary text-white text-xs font-bold uppercase tracking-wider">
|
||||
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 */}
|
||||
<div className="p-5">
|
||||
<h3 className="text-xl font-bold text-text mb-2">{project.title}</h3>
|
||||
<p className="text-text/70 text-sm mb-4 line-clamp-2">
|
||||
{project.description}
|
||||
</p>
|
||||
{project.note && (
|
||||
<p className="text-text/60 text-xs mb-4">{project.note}</p>
|
||||
)}
|
||||
|
||||
{/* Tech Stack */}
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{project.techStack.map((tech) => (
|
||||
<span
|
||||
key={tech}
|
||||
className="px-2 py-1 text-xs rounded-md bg-secondary/50 text-text/80 border border-secondary"
|
||||
>
|
||||
{tech}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onPlayVideo}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary/20 text-primary border border-primary/30 hover:bg-primary/30 anim-base text-sm font-medium"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
</svg>
|
||||
Watch Demo
|
||||
</button>
|
||||
{project.liveUrl && (
|
||||
<a
|
||||
href={project.liveUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-secondary/30 text-text border border-secondary hover:border-primary hover:text-primary anim-base text-sm font-medium"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||
<polyline points="15 3 21 3 21 9" />
|
||||
<line x1="10" y1="14" x2="21" y2="3" />
|
||||
</svg>
|
||||
Visit Site
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MoreToComCard() {
|
||||
return (
|
||||
<div className="relative rounded-2xl border border-dashed border-secondary bg-secondary/10 overflow-hidden flex items-center justify-center min-h-[300px]">
|
||||
<div className="text-center p-6">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-secondary/30 flex items-center justify-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-text/50"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-text/70 mb-2">More to Come</h3>
|
||||
<p className="text-text/50 text-sm">
|
||||
Exciting projects in development. Stay tuned!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Projects() {
|
||||
const [activeVideo, setActiveVideo] = useState<Project | null>(null);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 md:px-10 lg:px-16 py-16 md:py-24">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl md:text-4xl font-extrabold text-text mb-4 font-title">
|
||||
Projects
|
||||
</h2>
|
||||
<p className="text-text/70 max-w-2xl mx-auto">
|
||||
A showcase of my work — from concept to deployment. Click on any
|
||||
project to watch a demo.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{projects.map((project) => (
|
||||
<ProjectCard
|
||||
key={project.id}
|
||||
project={project}
|
||||
onPlayVideo={() => setActiveVideo(project)}
|
||||
/>
|
||||
))}
|
||||
<MoreToComCard />
|
||||
</div>
|
||||
|
||||
{/* Video Modal */}
|
||||
<VideoModal
|
||||
isOpen={activeVideo !== null}
|
||||
onClose={() => setActiveVideo(null)}
|
||||
video={activeVideo?.video || ""}
|
||||
videoMobile={activeVideo?.videoMobile || ""}
|
||||
title={activeVideo?.title || ""}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import { resumeData } from "../data/resume";
|
||||
|
||||
export function Resume() {
|
||||
const { contactInfo, summary, skills, certifications, projects, workHistory, education } = resumeData;
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl px-4 py-16 md:py-24">
|
||||
{/* Header */}
|
||||
<div className="mb-10 text-center anim-fade-in">
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
{contactInfo.website && (
|
||||
<>
|
||||
<span className="hidden sm:inline text-primary">•</span>
|
||||
<a href={contactInfo.website} className="hover:text-primary anim-base">
|
||||
{new URL(contactInfo.website).hostname}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
{contactInfo.linkedin && (
|
||||
<>
|
||||
<span className="hidden sm:inline text-primary">•</span>
|
||||
<a href={contactInfo.linkedin} className="hover:text-primary anim-base">
|
||||
LinkedIn
|
||||
</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>
|
||||
|
||||
{/* Summary */}
|
||||
<section className="mb-10 anim-fade-in">
|
||||
<SectionTitle>Summary</SectionTitle>
|
||||
<p className="text-text/85 leading-relaxed">{summary}</p>
|
||||
</section>
|
||||
|
||||
{/* Skills */}
|
||||
<section className="mb-10 anim-fade-in">
|
||||
<SectionTitle>Skills & Strengths</SectionTitle>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{skills.map((skill) => (
|
||||
<div
|
||||
key={skill.category}
|
||||
className="rounded-xl border border-secondary bg-secondary/20 p-4 anim-base hover:border-primary/50"
|
||||
>
|
||||
<h4 className="font-semibold text-primary mb-2 font-title">{skill.category}</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{skill.items.map((item) => (
|
||||
<span
|
||||
key={item}
|
||||
className="inline-block rounded-full bg-secondary/60 px-3 py-1 text-xs text-text/80"
|
||||
>
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Certifications */}
|
||||
{certifications && certifications.length > 0 && (
|
||||
<section className="mb-10 anim-fade-in">
|
||||
<SectionTitle>Certifications</SectionTitle>
|
||||
<ul className="space-y-3">
|
||||
{certifications.map((item, i) => (
|
||||
<li key={i} className="flex gap-3 text-text/85">
|
||||
<span className="mt-2 h-2 w-2 shrink-0 rounded-full bg-primary" />
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Projects */}
|
||||
{projects && projects.length > 0 && (
|
||||
<section className="mb-10 anim-fade-in">
|
||||
<SectionTitle>Projects</SectionTitle>
|
||||
<div className="space-y-4">
|
||||
{projects.map((project) => (
|
||||
<div
|
||||
key={project.name}
|
||||
className="rounded-lg border border-secondary bg-secondary/20 p-4 anim-base hover:border-primary/50"
|
||||
>
|
||||
<h4 className="font-semibold text-text">
|
||||
{project.name} <span className="text-text/70">| {project.stack}</span>
|
||||
</h4>
|
||||
<ul className="mt-2 space-y-2">
|
||||
{project.bullets.map((b, i) => (
|
||||
<li key={i} className="flex gap-3 text-text/85">
|
||||
<span className="mt-2 h-2 w-2 shrink-0 rounded-full bg-primary" />
|
||||
<span>{b}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Work History */}
|
||||
<section className="mb-10 anim-fade-in">
|
||||
<SectionTitle>Work History</SectionTitle>
|
||||
<div className="space-y-4">
|
||||
{workHistory.map((job, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex flex-col rounded-lg border border-secondary bg-secondary/20 p-4 anim-base hover:border-primary/50"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h4 className="font-semibold text-text">{job.title}</h4>
|
||||
<p className="text-text/70 text-sm">
|
||||
{job.company} — {job.location}
|
||||
</p>
|
||||
</div>
|
||||
<span className="mt-2 sm:mt-0 text-sm text-primary font-medium">{job.dates}</span>
|
||||
</div>
|
||||
{job.bullets && job.bullets.length > 0 && (
|
||||
<ul className="mt-2 space-y-2">
|
||||
{job.bullets.map((b, idx) => (
|
||||
<li key={idx} className="flex gap-3 text-text/85">
|
||||
<span className="mt-2 h-2 w-2 shrink-0 rounded-full bg-primary" />
|
||||
<span>{b}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Education */}
|
||||
<section className="anim-fade-in">
|
||||
<SectionTitle>Education</SectionTitle>
|
||||
<div className="space-y-4">
|
||||
{education.map((edu, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between rounded-lg border border-secondary bg-secondary/20 p-4 anim-base hover:border-primary/50"
|
||||
>
|
||||
<div>
|
||||
<h4 className="font-semibold text-text">{edu.degree}</h4>
|
||||
<p className="text-text/70 text-sm">
|
||||
{edu.school}
|
||||
{edu.details ? ` | ${edu.details}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<span className="mt-2 sm:mt-0 text-sm text-primary font-medium">{edu.date}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<h3 className="text-2xl font-bold font-title text-text">{children}</h3>
|
||||
<div className="mt-1 h-0.5 w-16 bg-gradient-to-r from-primary to-transparent rounded-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
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}
|
||||
|
||||
@@ -1,84 +1,48 @@
|
||||
// ThemeToggle.tsx
|
||||
import { useState } from "react";
|
||||
import React from "react";
|
||||
import { useTheme } from "../hooks/useTheme";
|
||||
|
||||
// Actual primary colors for each theme
|
||||
const themeColors: Record<string, { primary: string; label: string }> = {
|
||||
a: { primary: "#3d8eff", label: "Blue" },
|
||||
b: { primary: "#ff7043", label: "Ember" },
|
||||
c: { primary: "#00a3c4", label: "Teal" },
|
||||
d: { primary: "#7743d8", label: "Violet" },
|
||||
e: { primary: "#00d2a2", label: "Emerald" },
|
||||
};
|
||||
|
||||
export function ThemeToggle({ compact = false }: { compact?: boolean }) {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [open, setOpen] = useState(false);
|
||||
const themes = ["a", "b", "c", "d", "e"] as const;
|
||||
|
||||
const crossfadeTo = (next: typeof themes[number]) => {
|
||||
// 1) capture current hero computed background (all layers resolved)
|
||||
const hero = document.querySelector<HTMLElement>(".bg-hero");
|
||||
const prevBg = hero ? getComputedStyle(hero).backgroundImage : "";
|
||||
|
||||
// 2) stash it in a CSS var & flag crossfade
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty("--hero-bg-prev", prevBg);
|
||||
root.classList.add("hero-xfade");
|
||||
|
||||
// 3) switch theme (your existing logic)
|
||||
setTheme(next);
|
||||
setOpen(false);
|
||||
|
||||
// 4) remove crossfade flag after the animation
|
||||
window.setTimeout(() => {
|
||||
root.classList.remove("hero-xfade");
|
||||
root.style.removeProperty("--hero-bg-prev");
|
||||
}, 600); // a bit > .55s animation
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative inline-block text-text">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
className="cursor-pointer select-none inline-flex items-center gap-2 rounded px-3 py-1.5 bg-secondary/70 hover:bg-secondary focus:outline-none anim-base hover-pop"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<span className="font-medium">{compact ? "Theme" : "Toggle Theme"}</span>
|
||||
<span aria-hidden>{open ? "▴" : "▾"}</span>
|
||||
</button>
|
||||
|
||||
<details className="group">
|
||||
<summary className="cursor-pointer select-none list-none inline-flex items-center gap-2 rounded px-3 py-1.5 bg-secondary/70 hover:bg-secondary focus:outline-none">
|
||||
<span className="font-medium">
|
||||
{compact ? "Theme" : "Toggle Theme"}
|
||||
</span>
|
||||
<span aria-hidden>▾</span>
|
||||
</summary>
|
||||
<div
|
||||
className={`
|
||||
absolute top-full mt-2 z-[70] rounded-lg border border-secondary bg-bg/95 p-2 shadow-xl backdrop-blur
|
||||
left-4 right-4 mx-auto w-[calc(100vw-2rem)] max-w-[18rem]
|
||||
md:left-auto md:right-0 md:mx-0 md:w-44 md:max-w-none
|
||||
origin-top transition-[opacity,transform] duration-200 ease-out
|
||||
${open ? "opacity-100 scale-100 translate-y-0 pointer-events-auto" : "opacity-0 scale-95 -translate-y-1 pointer-events-none"}
|
||||
`}
|
||||
className="
|
||||
absolute top-full mt-2
|
||||
left-0 right-0 w-[calc(100vw-10rem)]
|
||||
md:left-auto md:right- md:mx-0 md:w-44
|
||||
rounded-lg border border-secondary bg-bg/95 p-2 shadow-xl backdrop-blur z-[70]
|
||||
"
|
||||
>
|
||||
<ul className="space-y-1" role="listbox">
|
||||
<ul className="space-y-1">
|
||||
{themes.map((t) => (
|
||||
<li key={t}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => crossfadeTo(t)}
|
||||
className={`w-full rounded px-3 py-2 text-left hover:bg-secondary/60 anim-base ${
|
||||
onClick={() => setTheme(t as any)}
|
||||
className={`w-full rounded px-3 py-2 text-left hover:bg-secondary/60 ${
|
||||
theme === t ? "outline outline-1 outline-primary" : ""
|
||||
}`}
|
||||
>
|
||||
{/* preview dot uses theme accent variables you defined per theme (optional) */}
|
||||
<span
|
||||
className="mr-2 inline-block h-3 w-3 rounded-full align-middle"
|
||||
style={{ background: themeColors[t].primary }}
|
||||
style={{ background: `var(--color-accent-${t})` }}
|
||||
/>
|
||||
{themeColors[t].label}
|
||||
Theme {t.toUpperCase()}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
export type ResumeData = {
|
||||
contactInfo: {
|
||||
name?: string;
|
||||
location: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
website?: string;
|
||||
linkedin?: string;
|
||||
github?: 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",
|
||||
phone: "806.654.2813",
|
||||
email: "jody@jodyholt.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.",
|
||||
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"] },
|
||||
],
|
||||
certifications: [
|
||||
"Meta Front-End Developer Certificate",
|
||||
"Meta Data Engineering Certificate",
|
||||
"AI Software Engineering Certificate",
|
||||
],
|
||||
projects: [
|
||||
{
|
||||
name: "SkyMoney Budgeting App (Beta)",
|
||||
stack: "React, TypeScript, Node.js, Prisma, PostgreSQL, Docker",
|
||||
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.",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Responsive Portfolio Website",
|
||||
stack: "React, TypeScript, Tailwind CSS, Vite",
|
||||
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.",
|
||||
],
|
||||
},
|
||||
],
|
||||
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",
|
||||
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."],
|
||||
},
|
||||
],
|
||||
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" },
|
||||
],
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:ital,wght@0,100..700;1,100..700&family=Oxanium:wght@200..800&family=Quantico:ital,wght@0,400;0,700;1,400;1,700&display=swap');
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Base design tokens that generate classes like bg-primary, text-text, etc. */
|
||||
@@ -10,10 +9,9 @@
|
||||
--color-tertiary: #00c9a7;
|
||||
--color-contrast: #9ca3af;
|
||||
|
||||
--font-main: "IBM Plex Sans", sans-serif;
|
||||
--font-title: "Oxanium", sans-serif;
|
||||
--font-name: "Quantico", sans-serif;
|
||||
|
||||
--font-main: ui-sans-serif, system-ui, "Inter", "Segoe UI", sans-serif;
|
||||
--font-title: "Nunito Sans", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-bold: "Bebas Neue", ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* A–E themes: override the same tokens inside an attribute scope */
|
||||
@@ -48,7 +46,7 @@ html[data-theme="d"] {
|
||||
--color-bg: #0f1014;
|
||||
--color-secondary: #1d1f24;
|
||||
--color-text: #eaecef;
|
||||
--color-primary: #7743d8;
|
||||
--color-primary: #6c78ff;
|
||||
--color-tertiary: #a97bff;
|
||||
--color-contrast: #9ca3af;
|
||||
}
|
||||
@@ -78,21 +76,21 @@ html[data-theme="e"] {
|
||||
),
|
||||
/* Slight linear sweep from top to bottom */
|
||||
linear-gradient(
|
||||
120deg,
|
||||
180deg,
|
||||
#0a0d13 0%,
|
||||
var(--color-bg) 20%,
|
||||
color-mix(in oklab, var(--color-primary) 15%, var(--color-bg) 90%)
|
||||
var(--color-bg) 40%,
|
||||
color-mix(in oklab, var(--color-primary) 10%, var(--color-bg) 90%)
|
||||
100%
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
/* Desktop override */
|
||||
@media (min-width: 768px) {
|
||||
.bg-hero {
|
||||
background:
|
||||
/* small, softer highlight lower than the portrait rim */ radial-gradient(
|
||||
95% 70% at 50% 28%,
|
||||
color-mix(in oklab, var(--hero-core) 24%, transparent 82%) 0%,
|
||||
color-mix(in oklab, var(--hero-core) 18%, transparent 82%) 0%,
|
||||
transparent 56%
|
||||
),
|
||||
/* gentle bottom vignette for depth */
|
||||
@@ -110,19 +108,18 @@ html[data-theme="e"] {
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
/* Theme A – deep blue */
|
||||
html[data-theme="a"] .bg-hero {
|
||||
background: radial-gradient(
|
||||
135% 120% at 80% 48%,
|
||||
color-mix(in oklab, var(--color-primary) 50%, black 35%) 0%,
|
||||
color-mix(in oklab, var(--color-primary) 70%, black 55%) 38%,
|
||||
transparent 90%
|
||||
color-mix(in oklab, var(--color-primary) 65%, black 35%) 0%,
|
||||
color-mix(in oklab, var(--color-primary) 45%, black 55%) 38%,
|
||||
transparent 74%
|
||||
),
|
||||
linear-gradient(
|
||||
230deg,
|
||||
165deg,
|
||||
#080b10 0%,
|
||||
color-mix(in oklab, var(--color-bg) 70%, black 30%) 46%,
|
||||
#0a1324 100%
|
||||
@@ -132,27 +129,27 @@ html[data-theme="e"] {
|
||||
html[data-theme="b"] .bg-hero {
|
||||
background: radial-gradient(
|
||||
140% 110% at 76% 46%,
|
||||
color-mix(in oklab, var(--color-primary) 50%, black 40%) 0%,
|
||||
color-mix(in oklab, var(--color-primary) 70%, black 60%) 36%,
|
||||
transparent 82%
|
||||
color-mix(in oklab, var(--color-primary) 60%, black 40%) 0%,
|
||||
color-mix(in oklab, var(--color-primary) 40%, black 60%) 36%,
|
||||
transparent 70%
|
||||
),
|
||||
linear-gradient(
|
||||
230deg,
|
||||
185deg,
|
||||
#140c0b 0%,
|
||||
var(--color-bg) 20%,
|
||||
color-mix(in oklab, var(--color-secondary) 6%, var(--color-bg) 94%) 100%
|
||||
var(--color-bg) 40%,
|
||||
color-mix(in oklab, var(--color-tertiary) 6%, var(--color-bg) 94%) 100%
|
||||
);
|
||||
}
|
||||
/* Theme C – teal/cyan */
|
||||
html[data-theme="c"] .bg-hero {
|
||||
background: radial-gradient(
|
||||
140% 120% at 76% 48%,
|
||||
color-mix(in oklab, var(--color-primary) 50%, black 42%) 0%,
|
||||
color-mix(in oklab, var(--color-primary) 70%, black 60%) 36%,
|
||||
transparent 82%
|
||||
color-mix(in oklab, var(--color-primary) 58%, black 42%) 0%,
|
||||
color-mix(in oklab, var(--color-primary) 40%, black 60%) 36%,
|
||||
transparent 72%
|
||||
),
|
||||
linear-gradient(
|
||||
230deg,
|
||||
165deg,
|
||||
#081016 0%,
|
||||
color-mix(in oklab, var(--color-bg) 62%, black 38%) 44%,
|
||||
#0a1822 100%
|
||||
@@ -163,12 +160,12 @@ html[data-theme="e"] {
|
||||
html[data-theme="d"] .bg-hero {
|
||||
background: radial-gradient(
|
||||
135% 120% at 80% 48%,
|
||||
color-mix(in oklab, var(--color-primary) 50%, black 40%) 0%,
|
||||
color-mix(in oklab, var(--color-primary) 70%, black 62%) 36%,
|
||||
transparent 82%
|
||||
color-mix(in oklab, var(--color-primary) 60%, black 40%) 0%,
|
||||
color-mix(in oklab, var(--color-primary) 38%, black 62%) 36%,
|
||||
transparent 72%
|
||||
),
|
||||
linear-gradient(
|
||||
230deg,
|
||||
165deg,
|
||||
#090a10 0%,
|
||||
color-mix(in oklab, var(--color-bg) 68%, black 32%) 46%,
|
||||
#111328 100%
|
||||
@@ -179,12 +176,12 @@ html[data-theme="e"] {
|
||||
html[data-theme="e"] .bg-hero {
|
||||
background: radial-gradient(
|
||||
140% 120% at 78% 48%,
|
||||
color-mix(in oklab, var(--color-primary) 50%, black 42%) 0%,
|
||||
color-mix(in oklab, var(--color-primary) 70%, black 62%) 34%,
|
||||
transparent 82%
|
||||
color-mix(in oklab, var(--color-primary) 58%, black 42%) 0%,
|
||||
color-mix(in oklab, var(--color-primary) 38%, black 62%) 34%,
|
||||
transparent 70%
|
||||
),
|
||||
linear-gradient(
|
||||
230deg,
|
||||
165deg,
|
||||
#07100e 0%,
|
||||
color-mix(in oklab, var(--color-bg) 64%, black 36%) 44%,
|
||||
#0a1c1a 100%
|
||||
@@ -199,19 +196,12 @@ html[data-theme="e"] {
|
||||
pointer-events: none;
|
||||
/* left-to-right fade of darkness */
|
||||
background: linear-gradient(
|
||||
270deg,
|
||||
90deg,
|
||||
rgba(0, 0, 0, 0.5) 0%,
|
||||
rgba(0, 0, 0, 0.34) 16%,
|
||||
rgba(0, 0, 0, 0.18) 35%,
|
||||
rgba(0, 0, 0, 0.34) 30%,
|
||||
rgba(0, 0, 0, 0.18) 42%,
|
||||
rgba(0, 0, 0, 0) 50%
|
||||
),
|
||||
linear-gradient(
|
||||
270deg,
|
||||
rgba(0,0,0,0.6) 0%,
|
||||
rgba(0,0,0,0.48) 25%,
|
||||
rgba(0,0,0,0.1) 60%
|
||||
);
|
||||
;
|
||||
z-index: 0;
|
||||
}
|
||||
/* keep content above the overlay */
|
||||
@@ -240,52 +230,3 @@ html[data-theme="e"] {
|
||||
box-shadow: inset 0 0 24px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Keyframes ─────────────────────────────────────────── */
|
||||
/* ── Keyframes (unchanged) ───────────────────────────── */
|
||||
@keyframes ui-fade-in { from{opacity:0;transform:translateY(6px)} to{opacity:1;transform:translateY(0)} }
|
||||
@keyframes ui-pop-in { from{opacity:0;transform:scale(.96);filter:blur(2px)} to{opacity:1;transform:scale(1);filter:blur(0)} }
|
||||
@keyframes ui-fade { from{opacity:0} to{opacity:1} }
|
||||
/* Old→new gradient crossfade */
|
||||
@keyframes hero-xfade-out { from { opacity: 1 } to { opacity: 0 } }
|
||||
|
||||
/* When html has .hero-xfade, paint the OLD gradient on ::after and fade it out */
|
||||
html.hero-xfade .bg-hero {
|
||||
position: relative; /* anchor overlay */
|
||||
}
|
||||
html.hero-xfade .bg-hero::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2; /* above everything but below menus if needed */
|
||||
pointer-events: none;
|
||||
background-image: var(--hero-bg-prev, none);
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
animation: hero-xfade-out .55s ease-out forwards; /* match your new durations */
|
||||
}
|
||||
|
||||
/* Ensure the base gradient is behind content as usual */
|
||||
.bg-hero > * { position: relative; z-index: 3; }
|
||||
|
||||
|
||||
/* Respect reduced motion (unchanged) */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* { animation-duration: 0.001ms !important; animation-iteration-count: 1 !important; transition-duration: 0.001ms !important; }
|
||||
}
|
||||
|
||||
/* ── Helpers (longer durations) ──────────────────────── */
|
||||
.anim-base { transition: transform .24s ease, opacity .24s ease, filter .24s ease, color .24s ease, background-color .24s ease, border-color .24s ease; }
|
||||
.anim-fade-in{ animation: ui-fade-in .55s cubic-bezier(.22,.61,.36,1) both; } /* 550ms */
|
||||
.anim-pop-in { animation: ui-pop-in .48s cubic-bezier(.22,.61,.36,1) both; } /* 480ms */
|
||||
.anim-fade { animation: ui-fade .45s ease-out both; } /* 450ms */
|
||||
|
||||
/* Optional: slightly gentler hover/tap */
|
||||
.hover-pop:hover { transform: translateY(-2px) scale(1.03); transition-duration: .24s; }
|
||||
.hover-pop:active { transform: translateY(0) scale(.98); transition-duration: .14s; }
|
||||
|
||||
/* ── Theme fade-on-switch (keeps it simple) ──────────── */
|
||||
/* When the theme changes (html[data-theme] switches), the hero softly fades in */
|
||||
html[data-theme] .bg-hero { animation: ui-fade .5s ease-out both; } /* 500ms */
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||