Restore contact form and allow new host

This commit is contained in:
2026-05-13 17:39:16 +02:00
parent c1891951c0
commit be65ff51b4
4 changed files with 243 additions and 18 deletions

View File

@@ -11,6 +11,7 @@ RATE_LIMIT_MAX_REQUESTS = 5
MESSAGES_STORAGE = "portfolio_contact_messages"
ALLOWED_ORIGINS = {
"https://luna.reversed.dev",
"https://luna.spaceistyping.com",
"http://localhost:5173",
"http://localhost:4173",
}
@@ -18,7 +19,7 @@ EMAIL_RE = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$")
def _cors_headers(origin=""):
allowed_origin = origin if origin in ALLOWED_ORIGINS else "https://luna.reversed.dev"
allowed_origin = origin if origin in ALLOWED_ORIGINS else "https://luna.spaceistyping.com"
return {
"Access-Control-Allow-Origin": allowed_origin,
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",

View File

@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react';
const REDIRECT_HOST = 'luna.reversed.dev';
const REDIRECT_TARGET = 'https://luna.spaceistyping.com';
const REDIRECT_DELAY_SECONDS = 5;
const CONTACT_API_URL = import.meta.env.VITE_CONTACT_API_URL || 'https://shsf-api.reversed.dev/api/exec/17/cba6645c-2ca2-4e7a-ad94-e6114cbde761';
const capabilities = [
{
@@ -49,8 +50,8 @@ const principles = [
const contactCards = [
{
label: 'email',
value: 'space@reversed.dev',
href: 'mailto:space@reversed.dev',
value: 'clawy@reversed.dev',
href: 'mailto:clawy@reversed.dev',
},
{
label: 'gitea',
@@ -59,8 +60,17 @@ const contactCards = [
},
];
const initialForm = {
username: '',
email: '',
message: '',
};
function App() {
const [secondsLeft, setSecondsLeft] = useState(REDIRECT_DELAY_SECONDS);
const [form, setForm] = useState(initialForm);
const [status, setStatus] = useState({ type: '', message: '' });
const [isSubmitting, setIsSubmitting] = useState(false);
const shouldRedirect = typeof window !== 'undefined' && window.location.hostname === REDIRECT_HOST;
useEffect(() => {
@@ -84,6 +94,47 @@ function App() {
};
}, [shouldRedirect]);
const handleChange = (event) => {
const { name, value } = event.target;
setForm((current) => ({ ...current, [name]: value }));
};
const handleSubmit = async (event) => {
event.preventDefault();
setIsSubmitting(true);
setStatus({ type: '', message: '' });
try {
const response = await fetch(CONTACT_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(form),
});
const data = await response.json().catch(() => ({}));
const payload = data?._res && typeof data._res === 'object' ? data._res : data;
if (!response.ok) {
throw new Error(payload?.error || payload?.message || 'Something went wrong while sending the message.');
}
setStatus({
type: 'success',
message: payload?.message || 'Message received. It made it through.',
});
setForm(initialForm);
} catch (error) {
setStatus({
type: 'error',
message: error.message || 'Something went wrong while sending the message.',
});
} finally {
setIsSubmitting(false);
}
};
return (
<div className="site-shell">
{shouldRedirect && (
@@ -217,22 +268,90 @@ function App() {
</section>
<section id="contact" className="section">
<div className="contact card glass">
<div className="section-heading narrow left">
<p className="kicker">contact</p>
<h2>want to reach me?</h2>
<p>
easiest route is still a direct message, but these work too.
</p>
<div className="contact card glass contact-layout">
<div className="contact-intro">
<div className="section-heading narrow left">
<p className="kicker">contact</p>
<h2>want to reach me?</h2>
<p>
send a message straight from the site, or just email me if thats easier.
</p>
</div>
<div className="contact-list">
{contactCards.map((item) => (
<a key={item.label} href={item.href} className="contact-row" target={item.href.startsWith('http') ? '_blank' : undefined} rel="noreferrer">
<span>{item.label}</span>
<strong>{item.value}</strong>
</a>
))}
</div>
</div>
<div className="contact-list">
{contactCards.map((item) => (
<a key={item.label} href={item.href} className="contact-row" target={item.href.startsWith('http') ? '_blank' : undefined} rel="noreferrer">
<span>{item.label}</span>
<strong>{item.value}</strong>
</a>
))}
<div className="contact-form-wrap">
<div className="form-intro">
<h3>contact form</h3>
<p>messages go straight into the inbox backend, no crusty third-party form junk.</p>
</div>
<form onSubmit={handleSubmit} className="contact-form">
<label className="field">
<span>name</span>
<input
id="username"
name="username"
type="text"
value={form.username}
onChange={handleChange}
minLength={2}
maxLength={80}
required
placeholder="what should this site call you?"
/>
</label>
<label className="field">
<span>email</span>
<input
id="email"
name="email"
type="email"
value={form.email}
onChange={handleChange}
maxLength={200}
required
placeholder="name@example.com"
/>
</label>
<label className="field">
<span>message</span>
<textarea
id="message"
name="message"
value={form.message}
onChange={handleChange}
minLength={10}
maxLength={5000}
required
rows={6}
placeholder="say something good. or interesting. ideally both."
/>
</label>
{status.message ? (
<div className={`form-status ${status.type === 'success' ? 'is-success' : 'is-error'}`}>
{status.message}
</div>
) : null}
<div className="form-footer">
<p>rate limited per ip, because spam bots are annoying.</p>
<button type="submit" disabled={isSubmitting} className="button button-primary submit-button">
{isSubmitting ? 'sending...' : 'send message'}
</button>
</div>
</form>
</div>
</div>
</section>

View File

@@ -470,6 +470,111 @@ h1 {
line-height: 1.5;
}
.contact-layout {
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
align-items: start;
}
.contact-intro,
.contact-form-wrap {
display: grid;
gap: 18px;
}
.form-intro h3 {
font-size: 1.2rem;
margin-bottom: 8px;
}
.form-intro p {
color: var(--muted);
line-height: 1.7;
}
.contact-form {
display: grid;
gap: 16px;
}
.field {
display: grid;
gap: 8px;
}
.field span,
.form-footer p {
color: var(--muted-soft);
font-size: 0.84rem;
text-transform: lowercase;
letter-spacing: 0.03em;
}
.field input,
.field textarea {
width: 100%;
border: 1px solid var(--border);
border-radius: 18px;
background: rgba(8, 11, 24, 0.72);
color: var(--text);
padding: 14px 16px;
font: inherit;
outline: none;
transition: border-color 160ms ease, box-shadow 160ms ease;
}
.field textarea {
resize: vertical;
min-height: 150px;
}
.field input::placeholder,
.field textarea::placeholder {
color: var(--muted-soft);
}
.field input:focus,
.field textarea:focus {
border-color: rgba(167, 139, 250, 0.5);
box-shadow: 0 0 0 4px rgba(167, 139, 250, 0.08);
}
.form-status {
padding: 14px 16px;
border-radius: 18px;
border: 1px solid var(--border);
line-height: 1.6;
}
.form-status.is-success {
border-color: rgba(16, 185, 129, 0.28);
background: rgba(16, 185, 129, 0.08);
color: #b8ffd9;
}
.form-status.is-error {
border-color: rgba(244, 63, 94, 0.28);
background: rgba(244, 63, 94, 0.08);
color: #ffc6d0;
}
.form-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.submit-button {
border: 0;
cursor: pointer;
}
.submit-button:disabled {
cursor: not-allowed;
opacity: 0.6;
}
@media (max-width: 960px) {
.hero,
.capability-grid,

View File

@@ -5,6 +5,6 @@ import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
preview: {
allowedHosts: ['luna.reversed.dev'],
allowedHosts: ['luna.reversed.dev', 'luna.spaceistyping.com'],
},
})