Initial clean commit

This commit is contained in:
2026-02-18 23:03:52 -06:00
commit 5b6eb16662
57 changed files with 8414 additions and 0 deletions

View File

@@ -0,0 +1,64 @@
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"

49
.gitignore vendored Normal file
View File

@@ -0,0 +1,49 @@
# 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)
.env
.env.*
!.env.example
!.env.*.example
contact-api/.env
contact-api/.env.*
!contact-api/.env.example
!contact-api/.env.*.example
# Archives and local release bundles
*.tgz
*.tar
*.tar.gz
# Tool caches
.eslintcache
.nyc_output/
# Editor / IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS generated
.DS_Store
Thumbs.db

91
README.md Normal file
View File

@@ -0,0 +1,91 @@
# Jody Holt | Developer Portfolio
This is the source code for my personal portfolio.
Created using React//Tailwind//TypeScript
---
## Overview
This site demonstrates my personality, skills, and presents the tone I provide through all my collaborative/business endeavors.
**STACK**
- SPA with React (functional components, hooks)
- TailwindCSS (custom tokens and theme system)
- Theme Engine (5 themes with automated color adaption per user preference )
- IntersectionObserver (based scroll nav)
- Modular component layout
---
## Project structure
```bash
src/
|-assets/ # Images
|-components/ #Reusable UI components
|-hooks/ #Custom React Hooks
|-styles/ #Tailwind + custom theme CSS
|App.tsx
|index.css
```
---
## Theme System
### within index:
#### 5 unique themes are laid 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.
## To run on local client:
```bash
npm i
npm run dev
## Local: http://localhost:5173/ (control + click local host url)
```
## Contact Form Setup
### Frontend env
Create `.env` in the project root:
```bash
VITE_CONTACT_API_URL=/api/contact
VITE_TURNSTILE_SITE_KEY=your_turnstile_site_key
```
### Backend API
The contact backend lives in `contact-api/`.
```bash
cd contact-api
npm i
npm run dev
```
Create `contact-api/.env` from `contact-api/.env.example` and set your real Turnstile + SMTP credentials.
### VPS service files
- systemd unit template: `ops/jody-contact-api.service`
- Nginx reverse proxy snippet: `ops/nginx-contact-api.conf`
## Author
### Jody Holt
### Frontend Developer • Passion Pioneer
#### [GitHub](https://github.com/Ricearoni1245) • [LinkedIn](https://www.linkedin.com/in/jody-holt-9b19b0256)

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

File diff suppressed because it is too large Load Diff

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

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

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

@@ -0,0 +1,43 @@
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;

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

@@ -0,0 +1,163 @@
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);
});

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

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

View File

@@ -0,0 +1,60 @@
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 };
}

View File

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

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

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

23
eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

35
h --force --all origin Normal file
View File

@@ -0,0 +1,35 @@
commit 4d5e05061c508d7207acd9afc1d7a686f39e7b2c (HEAD -> main)
Author: Ricearoni1245 <jholt1008@gmail.com>
Date: Wed Feb 18 22:58:19 2026 -0600
Remove tracked env files
commit a7b80c8318ea4ee3280e3d227f3a44e1f185bd3f (origin/main, origin/HEAD, github/main)
Author: Ricearoni1245 <jholt1008@gmail.com>
Date: Wed Feb 18 22:39:03 2026 -0600
fix: added better origin handling for contact form submission
commit 0ae4affe2095e9a1a6968744bfaa52d5e3559eaf
Author: Ricearoni1245 <jholt1008@gmail.com>
Date: Wed Feb 18 22:25:00 2026 -0600
added different domain types to origin allowed to see if that will work
commit 30e6c23bac21878911b95517a9260cdf4c0b5fe1
Author: Ricearoni1245 <jholt1008@gmail.com>
Date: Wed Feb 18 22:23:08 2026 -0600
removed duplicate reference of allowed_origin in env (bruh moment)
commit 5f0d87ce2afc6ecc11eebd9107780ed717ecef08
Author: Ricearoni1245 <jholt1008@gmail.com>
Date: Wed Feb 18 22:18:15 2026 -0600
added origin allowed for contact in env
commit beca15d9bf7c60c25d2c7af7f06555902973991d
Author: Ricearoni1245 <jholt1008@gmail.com>
Date: Wed Feb 18 22:14:16 2026 -0600
added env

18
index.html Normal file
View File

@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/jh-logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Jody Holt</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>

View File

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

View File

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

3933
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

35
package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "jody-holt-portfolio",
"private": true,
"version": "0.0.0",
"type": "module",
"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"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.16",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"tailwindcss": "^4.1.16"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
"@types/node": "^24.6.0",
"@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",
"globals": "^16.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.45.0",
"vite": "^7.1.7"
}
}

15
public/jh-logo.svg Normal file
View File

@@ -0,0 +1,15 @@
<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>

After

Width:  |  Height:  |  Size: 377 B

1
public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

42
src/App.css Normal file
View File

@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

78
src/App.tsx Normal file
View File

@@ -0,0 +1,78 @@
import { 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 { 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 refs = useRef<Record<string, HTMLElement | null>>({});
const [active, setActive] = useState<string>(sections[0]);
useEffect(() => {
const map: Record<string, HTMLElement | null> = {};
sections.forEach((id) => (map[id] = document.getElementById(id)));
refs.current = map;
const io = new IntersectionObserver(
(entries) => {
entries.forEach((e) => {
if (e.isIntersecting) setActive(e.target.id);
});
},
{ rootMargin: "-40% 0px -55% 0px", threshold: [0, 0.2, 1] }
);
Object.values(map).forEach((el) => el && io.observe(el));
return () => io.disconnect();
}, [sections]);
const handleNav = (id: string) => {
const el = refs.current[id];
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
};
return (
<div className="min-h-screen bg-bg text-text">
<Navbar onNav={handleNav} />
<main>
<Section id="home"><Hero /></Section>
<GradientBand />
<Section id="about"><AboutMe /></Section>
<GradientBand />
<Section id="projects"><Projects /></Section>
<GradientBand />
<Section id="experience"><Resume /></Section>
<GradientBand />
<Section id="contact"><Contact /></Section>
</main>
<Footer />
{/* Active section indicator (optional) */}
<div className="fixed bottom-4 right-4 rounded-full border border-secondary bg-bg/80 px-3 py-1 text-sm text-text/80 shadow">{active.toUpperCase()}</div>
</div>
);
}
function GradientBand() {
return <div className="h-px bg-gradient-to-r from-secondary via-primary/60 to-secondary" />;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 KiB

BIN
src/assets/img/Jody.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 964 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,41 @@
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>
);
}

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

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

108
src/components/Footer.tsx Normal file
View File

@@ -0,0 +1,108 @@
import githubIcon from "../assets/img/github-icon.png";
import linkedInIcon from "../assets/img/linkedin-icon.png";
import facebookIcon from "../assets/img/facebook-icon.png";
const defaultSocials = [
{ label: "GitHub", href: "https://github.com/Ricearoni1245", icon: githubIcon },
{ label: "LinkedIn", href: "https://www.linkedin.com/in/jody-holt-9b19b0256", icon: linkedInIcon },
{ label: "Facebook", href: "https://www.facebook.com/jody.holt.7161/", icon: facebookIcon },
];
type Social = { label: string; href: string; icon?: string };
export function Footer({
year = new Date().getFullYear(),
socials = defaultSocials,
showBackToTop = true,
}: {
year?: number;
socials?: Social[];
showBackToTop?: boolean;
}) {
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="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>
<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>
</nav>
<div className="flex items-center gap-4 text-text md:justify-end">
{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"
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" />
)}
</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>
{showBackToTop && (
<button
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
className="rounded px-3 py-1 text-xs text-text/70 hover:text-primary border border-secondary hover:border-primary"
>
Back to top
</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>
);
}

152
src/components/Hero.tsx Normal file
View File

@@ -0,0 +1,152 @@
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";
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">
<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"
>
Design. Develop. Deliver.
</h1>
<p className=" font-main text-sm text-text/80 anim-fade-in">
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="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"
/>
</div>
<h2 className="mt- font-extrabold font-title text-text leading-tight tracking-wide text-3xl anim-fade-in">
Hello, Im Jody Holt
</h2>
<p className=" font-main mt-2 text-[22px] text-base text-text/85 anim-fade-in">
Turning concepts into clean, functional code.
</p>
<div className="mt-5 mb-4 flex items-center justify-center gap-4">
{socialLinks.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"
>
<img src={a.icon} alt={a.label} className="h-6 w-6 invert brightness-0 invert opacity-90" />
</a>
))}
</div>
</div>
{/*DESKTOP*/}
{/*____________________________________________________________________________________________________*/}
<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"
>
<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 "
>
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"
>
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"
>
Im 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"
>
Turning concepts into clean, functional code.
</p>
<div className="flex self-start items-center justify-start gap-4 md:gap-6 mt-5">
{socialLinks.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"
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" />
</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>
);
}

98
src/components/Navbar.tsx Normal file
View File

@@ -0,0 +1,98 @@
import { 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: "projects", label: "Projects" },
{ id: "experience", label: "Resume" },
{ id: "contact", label: "Contact" },
];
const handleNav = (id: string) => {
onNav(id);
setOpen(false);
};
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">
<div className="leading-tight">
<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>
</div>
<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"
onClick={() => handleNav(l.id)}
>
{l.label}
</button>
))}
<ThemeToggle />
</nav>
<div className="md:hidden">
<button
aria-expanded={open}
aria-label="Toggle menu"
className="rounded px-3 py-2 text-text hover:bg-secondary/60"
onClick={() => setOpen((v) => !v)}
>
<svg viewBox="0 0 24 24" width="28" height="28" fill="currentColor">
<path
d="M3 6h18M3 12h18M3 18h18"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
</button>
</div>
</div>
<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"
}`}
>
<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"
onClick={() => handleNav(l.id)}
>
{l.label}
</button>
))}
<div className="pt-2">
<ThemeToggle compact />{" "}
</div>
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,17 @@
// =====================================
export function Placeholder({ title }: { title: string }) {
return (
<div className="mx-auto max-w-7xl px-4 py-24">
<h3 className="mb-6 text-3xl font-bold text-text">{title}</h3>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="rounded-xl border border-secondary bg-secondary/30 p-6 text-text/85">
Card {i + 1}
</div>
))}
</div>
</div>
);
}

338
src/components/Projects.tsx Normal file
View File

@@ -0,0 +1,338 @@
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";
type Project = {
id: string;
title: string;
description: string;
coverImage: string;
coverImageMobile: string;
video: string;
videoMobile: string;
techStack: string[];
liveUrl?: string;
beta?: 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,
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",
},
];
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>
)}
</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>
)}
</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>
);
}

169
src/components/Resume.tsx Normal file
View File

@@ -0,0 +1,169 @@
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.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>
</>
)}
</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>
);
}

View File

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

View File

@@ -0,0 +1,84 @@
// ThemeToggle.tsx
import { useState } 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>
<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"}
`}
>
<ul className="space-y-1" role="listbox">
{themes.map((t) => (
<li key={t}>
<button
type="button"
onClick={() => crossfadeTo(t)}
className={`w-full rounded px-3 py-2 text-left hover:bg-secondary/60 anim-base ${
theme === t ? "outline outline-1 outline-primary" : ""
}`}
>
<span
className="mr-2 inline-block h-3 w-3 rounded-full align-middle"
style={{ background: themeColors[t].primary }}
/>
{themeColors[t].label}
</button>
</li>
))}
</ul>
</div>
</div>
);
}

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

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

21
src/hooks/useTheme.ts Normal file
View File

@@ -0,0 +1,21 @@
import { useEffect, useState } from "react";
export type ThemeKey = "a" | "b" | "c" | "d" | "e";
export function useTheme() {
const [theme, setTheme] = useState<ThemeKey>(() => {
const saved = (typeof window !== "undefined" && localStorage.getItem("theme")) as ThemeKey | null;
return saved ?? "a";
});
useEffect(() => {
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem("theme", theme);
}, [theme]);
return { theme, setTheme };
}

291
src/index.css Normal file
View File

@@ -0,0 +1,291 @@
@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. */
@theme {
--color-bg: #0e1116; /* defaults = Theme A */
--color-text: #e1e8ee;
--color-primary: #3d8eff;
--color-secondary: #1a1f26;
--color-tertiary: #00c9a7;
--color-contrast: #9ca3af;
--font-main: "IBM Plex Sans", sans-serif;
--font-title: "Oxanium", sans-serif;
--font-name: "Quantico", sans-serif;
}
/* AE themes: override the same tokens inside an attribute scope */
html[data-theme="a"] {
--color-bg: #0e1116;
--color-secondary: #1a1f26;
--color-text: #e1e8ee;
--color-primary: #3d8eff;
--color-tertiary: #00c9a7;
--color-contrast: #9ca3af;
}
html[data-theme="b"] {
--color-bg: #120e0e;
--color-secondary: #1c1818;
--color-text: #fefcfb;
--color-primary: #ff7043;
--color-tertiary: #ffd166;
--color-contrast: #a3948c;
}
html[data-theme="c"] {
--color-bg: #0d1318;
--color-secondary: #1b242c;
--color-text: #e8ecef;
--color-primary: #00a3c4;
--color-tertiary: #ff8a70;
--color-contrast: #9ca3af;
}
html[data-theme="d"] {
--color-bg: #0f1014;
--color-secondary: #1d1f24;
--color-text: #eaecef;
--color-primary: #7743d8;
--color-tertiary: #a97bff;
--color-contrast: #9ca3af;
}
html[data-theme="e"] {
--color-bg: #0c1114;
--color-secondary: #182127;
--color-text: #edeff1;
--color-primary: #00d2a2;
--color-tertiary: #ffca57;
--color-contrast: #9ca3af;
}
/* theme-aware hero color follows --color-primary */
:root {
--hero-core: var(--color-primary);
}
@layer utilities {
/* Mobile / default */
.bg-hero {
background:
/* Top-right radial accent, similar to desktop */ radial-gradient(
120% 100% at 80% 10%,
color-mix(in oklab, var(--color-primary) 32%, transparent) 0%,
transparent 60%
),
/* Slight linear sweep from top to bottom */
linear-gradient(
120deg,
#0a0d13 0%,
var(--color-bg) 20%,
color-mix(in oklab, var(--color-primary) 15%, 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%,
transparent 56%
),
/* gentle bottom vignette for depth */
radial-gradient(
130% 90% at 50% 120%,
rgba(0, 0, 0, 0.32) 0%,
rgba(0, 0, 0, 0) 58%
),
/* base linear sweep */
linear-gradient(
185deg,
#0b0f15 0%,
var(--color-bg) 40%,
color-mix(in oklab, var(--hero-core) 12%, var(--color-bg) 88%) 100%
);
}
}
}
@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%
),
linear-gradient(
230deg,
#080b10 0%,
color-mix(in oklab, var(--color-bg) 70%, black 30%) 46%,
#0a1324 100%
);
}
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%
),
linear-gradient(
230deg,
#140c0b 0%,
var(--color-bg) 20%,
color-mix(in oklab, var(--color-secondary) 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%
),
linear-gradient(
230deg,
#081016 0%,
color-mix(in oklab, var(--color-bg) 62%, black 38%) 44%,
#0a1822 100%
);
}
/* Theme D indigo/violet */
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%
),
linear-gradient(
230deg,
#090a10 0%,
color-mix(in oklab, var(--color-bg) 68%, black 32%) 46%,
#111328 100%
);
}
/* Theme E emerald */
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%
),
linear-gradient(
230deg,
#07100e 0%,
color-mix(in oklab, var(--color-bg) 64%, black 36%) 44%,
#0a1c1a 100%
);
}
}
@media (min-width: 768px) {
.bg-hero::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
/* left-to-right fade of darkness */
background: linear-gradient(
270deg,
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) 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 */
.bg-hero > * {
position: relative;
z-index: 1;
}
}
@layer utilities {
.img-glow {
background:
radial-gradient(
68% 68% at 50% 42%,
color-mix(in oklab, var(--color-primary) 100%, black 0%) 0%,
color-mix(in oklab, var(--color-primary) 70%, black 30%) 40%,
color-mix(in oklab, var(--color-primary) 45%, black 55%) 70%,
color-mix(in oklab, var(--color-primary) 20%, black 80%) 85%
),
radial-gradient(
80% 80% at 50% 50%,
rgba(0,0,0,0) 58%,
rgba(0,0,0,0.35) 78%,
rgba(0,0,0,0.55) 100%
);
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 */

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

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

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

28
tsconfig.app.json Normal file
View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
tsconfig.node.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

11
vite.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [
react(),
tailwindcss(),
],
})