cloudlfare
This commit is contained in:
@@ -2,5 +2,5 @@ GITEA_URL=https://git.williammarch.xyz
|
|||||||
GITEA_USERNAME=m0dus
|
GITEA_USERNAME=m0dus
|
||||||
|
|
||||||
|
|
||||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY=0x4AAAAAACzsXdlmXw00QAai
|
NEXT_PUBLIC_TURNSTILE_SITE_KEY=0x4AAAAAACzsqWrzBq00jrzS
|
||||||
TURNSTILE_SECRET_KEY=0x4AAAAAACzsXeYTwCALD_kLJqaZESctCUo
|
TURNSTILE_SECRET_KEY=0x4AAAAAACzsqSwNTHM_tj3EG-q_Nwlxtm4
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export async function POST(req: Request) {
|
|||||||
// Send email via local sendmail (Postfix/Sendmail/Exim)
|
// Send email via local sendmail (Postfix/Sendmail/Exim)
|
||||||
await transporter.sendMail({
|
await transporter.sendMail({
|
||||||
from: 'William March <contact@williammarch.xyz>',
|
from: 'William March <contact@williammarch.xyz>',
|
||||||
to: 'your-real-inbox@example.com', // TODO: change to your inbox
|
to: 'qemuguest@protonmail.com',
|
||||||
replyTo: email,
|
replyTo: email,
|
||||||
subject: `New message from ${name} via williammarch.xyz`,
|
subject: `New message from ${name} via williammarch.xyz`,
|
||||||
text: `From: ${name} <${email}>\n\n${message}`,
|
text: `From: ${name} <${email}>\n\n${message}`,
|
||||||
|
|||||||
@@ -32,7 +32,10 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const md = await res.text();
|
const md = await res.text();
|
||||||
const html = marked.parse(md || '');
|
|
||||||
|
// marked.parse can return string | Promise<string>,
|
||||||
|
// so we call it in async mode and await the result.
|
||||||
|
const html = await marked.parse(md || '', { async: true });
|
||||||
|
|
||||||
return new NextResponse(html, {
|
return new NextResponse(html, {
|
||||||
status: 200,
|
status: 200,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export default function Contact() {
|
|||||||
const [turnstileToken, setTurnstileToken] = useState('');
|
const [turnstileToken, setTurnstileToken] = useState('');
|
||||||
const widgetIdRef = useRef<string | null>(null);
|
const widgetIdRef = useRef<string | null>(null);
|
||||||
const turnstileRef = useRef<HTMLDivElement | null>(null);
|
const turnstileRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const startedAtRef = useRef<number>(Date.now());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!TURNSTILE_SITE_KEY) {
|
if (!TURNSTILE_SITE_KEY) {
|
||||||
@@ -48,6 +49,7 @@ export default function Contact() {
|
|||||||
theme: 'dark',
|
theme: 'dark',
|
||||||
callback: token => {
|
callback: token => {
|
||||||
setTurnstileToken(token);
|
setTurnstileToken(token);
|
||||||
|
setErrorMessage('');
|
||||||
},
|
},
|
||||||
'expired-callback': () => {
|
'expired-callback': () => {
|
||||||
setTurnstileToken('');
|
setTurnstileToken('');
|
||||||
@@ -85,7 +87,7 @@ export default function Contact() {
|
|||||||
name: String(fd.get('name') || '').trim(),
|
name: String(fd.get('name') || '').trim(),
|
||||||
email: String(fd.get('email') || '').trim(),
|
email: String(fd.get('email') || '').trim(),
|
||||||
message: String(fd.get('message') || '').trim(),
|
message: String(fd.get('message') || '').trim(),
|
||||||
company: String(fd.get('company') || '').trim(), // honeypot
|
company: String(fd.get('company') || '').trim(),
|
||||||
turnstileToken,
|
turnstileToken,
|
||||||
formStartedAt: String(fd.get('formStartedAt') || ''),
|
formStartedAt: String(fd.get('formStartedAt') || ''),
|
||||||
};
|
};
|
||||||
@@ -117,7 +119,9 @@ export default function Contact() {
|
|||||||
|
|
||||||
setStatus('sent');
|
setStatus('sent');
|
||||||
form.reset();
|
form.reset();
|
||||||
|
startedAtRef.current = Date.now();
|
||||||
setTurnstileToken('');
|
setTurnstileToken('');
|
||||||
|
|
||||||
if (window.turnstile && widgetIdRef.current) {
|
if (window.turnstile && widgetIdRef.current) {
|
||||||
window.turnstile.reset(widgetIdRef.current);
|
window.turnstile.reset(widgetIdRef.current);
|
||||||
}
|
}
|
||||||
@@ -126,10 +130,11 @@ export default function Contact() {
|
|||||||
setErrorMessage(
|
setErrorMessage(
|
||||||
err instanceof Error ? err.message : 'Something went wrong.',
|
err instanceof Error ? err.message : 'Something went wrong.',
|
||||||
);
|
);
|
||||||
} finally {
|
|
||||||
if (status !== 'sent') {
|
if (window.turnstile && widgetIdRef.current) {
|
||||||
setStatus(prev => (prev === 'sent' ? 'sent' : 'idle'));
|
window.turnstile.reset(widgetIdRef.current);
|
||||||
}
|
}
|
||||||
|
setTurnstileToken('');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,6 +146,7 @@ export default function Contact() {
|
|||||||
>
|
>
|
||||||
<div className="max-w-5xl mx-auto">
|
<div className="max-w-5xl mx-auto">
|
||||||
<span className="label">Get in touch</span>
|
<span className="label">Get in touch</span>
|
||||||
|
|
||||||
<h2
|
<h2
|
||||||
id="contact-heading"
|
id="contact-heading"
|
||||||
className="text-[clamp(1.8rem,4vw,2.5rem)] font-black tracking-tight text-white mb-3"
|
className="text-[clamp(1.8rem,4vw,2.5rem)] font-black tracking-tight text-white mb-3"
|
||||||
@@ -153,17 +159,84 @@ export default function Contact() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="grid md:grid-cols-[280px_1fr] gap-6">
|
<div className="grid md:grid-cols-[280px_1fr] gap-6">
|
||||||
|
{/* Left side contact cards */}
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{/* email + gitea blocks unchanged */}
|
<a
|
||||||
{/* ... */}
|
href={`mailto:${SITE.email}`}
|
||||||
|
className="glass rounded-xl p-4 flex items-center gap-3 group hover:border-white/[0.14]
|
||||||
|
hover:-translate-x-[-4px] transition-all duration-150"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-8 h-8 rounded-lg bg-white/[0.05] flex items-center justify-center flex-shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="rgba(255,255,255,0.5)"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
>
|
||||||
|
<rect x="2" y="4" width="20" height="16" rx="2" />
|
||||||
|
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-[10px] text-white/25 uppercase tracking-wider">
|
||||||
|
Email
|
||||||
|
</p>
|
||||||
|
<p className="text-xs font-medium text-white/55 truncate group-hover:text-white/80 transition-colors">
|
||||||
|
{SITE.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={`${GITEA_URL}/${GITEA_USERNAME}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="glass rounded-xl p-4 flex items-center gap-3 group hover:border-white/[0.14]
|
||||||
|
hover:translate-x-1 transition-all duration-150"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-8 h-8 rounded-lg bg-white/[0.05] flex items-center justify-center flex-shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="rgba(255,255,255,0.5)"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
>
|
||||||
|
<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.2c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" />
|
||||||
|
<path d="M9 18c-4.51 2-5-2-7-2" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-[10px] text-white/25 uppercase tracking-wider">
|
||||||
|
Gitea
|
||||||
|
</p>
|
||||||
|
<p className="text-xs font-medium text-white/55 truncate group-hover:text-white/80 transition-colors">
|
||||||
|
{GITEA_URL.replace(/^https?:\/\//, '')}/{GITEA_USERNAME}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
className="glass rounded-xl p-6 flex flex-col gap-4"
|
className="glass rounded-xl p-6 flex flex-col gap-4 relative"
|
||||||
noValidate
|
noValidate
|
||||||
>
|
>
|
||||||
<input type="hidden" name="formStartedAt" value={String(Date.now())} />
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="formStartedAt"
|
||||||
|
value={String(startedAtRef.current)}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Honeypot */}
|
{/* Honeypot */}
|
||||||
<div
|
<div
|
||||||
@@ -180,8 +253,54 @@ export default function Contact() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* fields unchanged */}
|
<div className="grid sm:grid-cols-2 gap-4">
|
||||||
{/* ... */}
|
<label className="flex flex-col gap-1.5">
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-white/30">
|
||||||
|
Name
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="Your name"
|
||||||
|
autoComplete="name"
|
||||||
|
className="bg-white/[0.03] border border-white/[0.08] rounded-lg px-3 py-2.5 text-sm
|
||||||
|
text-white placeholder-white/20 outline-none transition-colors
|
||||||
|
focus:border-white/[0.2] focus:ring-1 focus:ring-white/[0.1]"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1.5">
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-white/30">
|
||||||
|
Email
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
placeholder="you@example.com"
|
||||||
|
autoComplete="email"
|
||||||
|
className="bg-white/[0.03] border border-white/[0.08] rounded-lg px-3 py-2.5 text-sm
|
||||||
|
text-white placeholder-white/20 outline-none transition-colors
|
||||||
|
focus:border-white/[0.2] focus:ring-1 focus:ring-white/[0.1]"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1.5">
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-white/30">
|
||||||
|
Message
|
||||||
|
</span>
|
||||||
|
<textarea
|
||||||
|
name="message"
|
||||||
|
required
|
||||||
|
placeholder="What’s on your mind?"
|
||||||
|
rows={5}
|
||||||
|
className="bg-white/[0.03] border border-white/[0.08] rounded-lg px-3 py-2.5 text-sm
|
||||||
|
text-white placeholder-white/20 outline-none resize-none transition-colors
|
||||||
|
focus:border-white/[0.2] focus:ring-1 focus:ring-white/[0.1]"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
{TURNSTILE_SITE_KEY ? (
|
{TURNSTILE_SITE_KEY ? (
|
||||||
@@ -199,7 +318,11 @@ export default function Contact() {
|
|||||||
disabled={status === 'loading'}
|
disabled={status === 'loading'}
|
||||||
className="btn btn-primary disabled:opacity-60 disabled:cursor-not-allowed"
|
className="btn btn-primary disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{status === 'loading' ? 'Sending…' : status === 'sent' ? 'Message sent' : 'Send message'}
|
{status === 'loading'
|
||||||
|
? 'Sending…'
|
||||||
|
: status === 'sent'
|
||||||
|
? 'Message sent'
|
||||||
|
: 'Send message'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{status === 'sent' && (
|
{status === 'sent' && (
|
||||||
@@ -208,7 +331,7 @@ export default function Contact() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{errorMessage && (
|
{status === 'error' && errorMessage && (
|
||||||
<p className="text-xs text-red-300/80">{errorMessage}</p>
|
<p className="text-xs text-red-300/80">{errorMessage}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
11
package-lock.json
generated
11
package-lock.json
generated
@@ -18,6 +18,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/nodemailer": "^7.0.11",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
"autoprefixer": "^10",
|
"autoprefixer": "^10",
|
||||||
@@ -321,6 +322,16 @@
|
|||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/nodemailer": {
|
||||||
|
"version": "7.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz",
|
||||||
|
"integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/prop-types": {
|
"node_modules/@types/prop-types": {
|
||||||
"version": "15.7.15",
|
"version": "15.7.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/nodemailer": "^7.0.11",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
"autoprefixer": "^10",
|
"autoprefixer": "^10",
|
||||||
|
|||||||
Reference in New Issue
Block a user