Restore contact form and allow new host
This commit is contained in:
@@ -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",
|
||||
|
||||
151
src/App.jsx
151
src/App.jsx
@@ -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 that’s 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>
|
||||
|
||||
105
src/index.css
105
src/index.css
@@ -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,
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user