update projects and contact form
This commit is contained in:
74
app/TechBackdrop.tsx
Normal file
74
app/TechBackdrop.tsx
Normal 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
109
app/api/contact/route.ts
Normal 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} <${email}></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 });
|
||||
}
|
||||
}
|
||||
43
app/api/gitea/readme/route.ts
Normal file
43
app/api/gitea/readme/route.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user