update projects and contact form

This commit is contained in:
will
2026-04-02 23:02:19 +01:00
parent ea864a99c4
commit 746e51370d
9776 changed files with 953 additions and 2075549 deletions

74
app/TechBackdrop.tsx Normal file
View File

@@ -0,0 +1,74 @@
// app/TechBackdrop.tsx
export default function TechBackdrop() {
return (
<svg
className="w-full h-full"
viewBox="0 0 1440 900"
preserveAspectRatio="xMidYMid slice"
>
<defs>
{/* Keep only a very subtle vignette, no bright white */}
<radialGradient id="bg-vignette" cx="50%" cy="0%" r="80%">
<stop offset="0%" stopColor="#02061700" /> {/* transparent */}
<stop offset="60%" stopColor="#02061720" /> {/* very soft */}
<stop offset="100%" stopColor="#020617ff" /> {/* solid base */}
</radialGradient>
<pattern
id="grid-lines"
x="0"
y="0"
width="120"
height="120"
patternUnits="userSpaceOnUse"
>
<path
d="M0 0 H120"
stroke="rgba(148,163,184,0.10)"
strokeWidth="0.4"
strokeDasharray="2 14"
/>
</pattern>
<style>
{`
@keyframes draw-line-soft {
0% { stroke-dashoffset: 520; opacity: 0.0; }
30% { stroke-dashoffset: 0; opacity: 0.25; }
70% { stroke-dashoffset: 0; opacity: 0.25; }
100% { stroke-dashoffset: -520; opacity: 0.0; }
}
`}
</style>
</defs>
{/* Flat dark blue base */}
<rect width="1440" height="900" fill="#020617" />
{/* Very subtle vignette over it */}
<rect width="1440" height="900" fill="url(#bg-vignette)" />
{/* Faint lines */}
<rect
x="-200"
y="160"
width="1800"
height="600"
fill="url(#grid-lines)"
opacity="0.22"
/>
{/* Soft trajectory line, toned down */}
<path
d="M-120 760 Q 540 420 1200 520 T 1680 260"
fill="none"
stroke="rgba(148,163,184,0.28)"
strokeWidth="0.7"
strokeDasharray="520 520"
strokeDashoffset="520"
style={{
animation: 'draw-line-soft 26s ease-in-out infinite',
}}
/>
</svg>
);
}

109
app/api/contact/route.ts Normal file
View File

@@ -0,0 +1,109 @@
import { NextResponse } from 'next/server';
import nodemailer from 'nodemailer';
const transporter = nodemailer.createTransport({
sendmail: true,
newline: 'unix',
path: '/usr/sbin/sendmail',
});
export async function POST(req: Request) {
try {
const {
name,
email,
message,
company, // honeypot
turnstileToken,
formStartedAt,
} = await req.json();
// Basic validation
if (!name || !email || !message) {
return NextResponse.json(
{ error: 'Missing required fields.' },
{ status: 400 },
);
}
// Honeypot: bots often fill this
if (company && String(company).trim().length > 0) {
return NextResponse.json({ error: 'Spam detected.' }, { status: 400 });
}
// Time-based spam check
const started = Number(formStartedAt);
if (!started || Date.now() - started < 3000) {
return NextResponse.json(
{ error: 'Submission too fast.' },
{ status: 400 },
);
}
// Turnstile validation
if (!turnstileToken) {
return NextResponse.json(
{ error: 'Missing captcha token.' },
{ status: 400 },
);
}
const secret = process.env.TURNSTILE_SECRET_KEY;
if (!secret) {
return NextResponse.json(
{ error: 'Turnstile secret not configured.' },
{ status: 500 },
);
}
const ip = req.headers.get('cf-connecting-ip') || '';
const verifyRes = await fetch(
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
secret,
response: turnstileToken,
remoteip: ip,
}),
},
);
const verifyData = (await verifyRes.json()) as {
success: boolean;
'error-codes'?: string[];
};
if (!verifyData.success) {
return NextResponse.json(
{
error: 'Captcha verification failed.',
details: verifyData['error-codes'] || [],
},
{ status: 400 },
);
}
// Send email via local sendmail (Postfix/Sendmail/Exim)
await transporter.sendMail({
from: 'William March <contact@williammarch.xyz>',
to: 'your-real-inbox@example.com', // TODO: change to your inbox
replyTo: email,
subject: `New message from ${name} via williammarch.xyz`,
text: `From: ${name} <${email}>\n\n${message}`,
html: `
<p><strong>From:</strong> ${name} &lt;${email}&gt;</p>
<p><strong>Message:</strong></p>
<p>${message.replace(/\n/g, '<br/>')}</p>
`,
});
return NextResponse.json({ ok: true });
} catch (error) {
const msg =
error instanceof Error ? error.message : 'Failed to send email.';
return NextResponse.json({ error: msg }, { status: 500 });
}
}

View File

@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from 'next/server';
import { marked } from 'marked';
const GITEA_URL = process.env.GITEA_URL;
const GITEA_USERNAME = process.env.GITEA_USERNAME;
if (!GITEA_URL || !GITEA_USERNAME) {
throw new Error('GITEA_URL and GITEA_USERNAME must be set in env');
}
export async function GET(req: NextRequest) {
const repo = req.nextUrl.searchParams.get('repo');
if (!repo) {
return new NextResponse('Missing repo', { status: 400 });
}
// raw README (default branch)
const readmeUrl = `${GITEA_URL}/api/v1/repos/${GITEA_USERNAME}/${encodeURIComponent(
repo,
)}/raw/README.md`;
const res = await fetch(readmeUrl, {
headers: { Accept: 'text/plain' },
cache: 'no-store',
});
if (!res.ok) {
return new NextResponse(
`Failed to fetch README: HTTP ${res.status}`,
{ status: 502 },
);
}
const md = await res.text();
const html = marked.parse(md || '');
return new NextResponse(html, {
status: 200,
headers: {
'Content-Type': 'text/html; charset=utf-8',
},
});
}

View File

@@ -189,3 +189,62 @@ body {
background: var(--faint);
}
.commit-line:last-child::before { display: none; }
.prose {
--tw-prose-body: rgba(226, 232, 240, 0.88); /* text-slate-200 */
--tw-prose-headings: #e5e7eb; /* text-slate-200 */
--tw-prose-links: #e5e7eb;
--tw-prose-links-hover: #ffffff;
--tw-prose-quotes: #e5e7eb;
--tw-prose-bold: #e5e7eb;
--tw-prose-bullets: rgba(148, 163, 184, 0.7);
--tw-prose-hr: rgba(148, 163, 184, 0.25);
--tw-prose-th-borders: rgba(148, 163, 184, 0.4);
--tw-prose-td-borders: rgba(148, 163, 184, 0.2);
}
.prose.prose-invert {
color: var(--tw-prose-body);
}
.prose.prose-invert h1,
.prose.prose-invert h2,
.prose.prose-invert h3,
.prose.prose-invert h4 {
letter-spacing: -0.02em;
}
.prose.prose-invert a {
text-decoration: none;
border-bottom: 1px dotted rgba(148, 163, 184, 0.6);
}
.prose.prose-invert a:hover {
border-bottom-style: solid;
border-bottom-color: rgba(248, 250, 252, 0.9);
}
.prose.prose-invert code {
font-size: 0.8rem;
background: rgba(15, 23, 42, 0.9);
border-radius: 0.35rem;
padding: 0.1rem 0.3rem;
}
.prose.prose-invert pre {
background: radial-gradient(circle at top left, #020617, #020617);
border-radius: 0.75rem;
border: 1px solid rgba(148, 163, 184, 0.25);
padding: 0.9rem 1rem;
}
.prose.prose-invert pre code {
background: transparent;
padding: 0;
}
.prose.prose-invert ul,
.prose.prose-invert ol {
margin-top: 0.75rem;
margin-bottom: 0.75rem;
}

View File

@@ -2,6 +2,7 @@ import type { Metadata } from 'next';
import { GeistSans } from 'geist/font/sans';
import { GeistMono } from 'geist/font/mono';
import './globals.css';
import TechBackdrop from './TechBackdrop';
export const metadata: Metadata = {
title: 'William March — Software Engineer',
@@ -13,13 +14,24 @@ export const metadata: Metadata = {
url: 'https://williammarch.xyz',
siteName: 'William March',
},
twitter: { card: 'summary_large_image', title: 'William March', description: 'Software engineer.' },
twitter: {
card: 'summary_large_image',
title: 'William March',
description: 'Software engineer.',
},
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={`${GeistSans.variable} ${GeistMono.variable}`}>
<body>{children}</body>
<body className="bg-slate-950 text-slate-100 relative overflow-x-hidden">
<div aria-hidden="true" className="fixed inset-0 -z-10">
<TechBackdrop />
</div>
<div className="relative">
{children}
</div>
</body>
</html>
);
}