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"
|
MESSAGES_STORAGE = "portfolio_contact_messages"
|
||||||
ALLOWED_ORIGINS = {
|
ALLOWED_ORIGINS = {
|
||||||
"https://luna.reversed.dev",
|
"https://luna.reversed.dev",
|
||||||
|
"https://luna.spaceistyping.com",
|
||||||
"http://localhost:5173",
|
"http://localhost:5173",
|
||||||
"http://localhost:4173",
|
"http://localhost:4173",
|
||||||
}
|
}
|
||||||
@@ -18,7 +19,7 @@ EMAIL_RE = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$")
|
|||||||
|
|
||||||
|
|
||||||
def _cors_headers(origin=""):
|
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 {
|
return {
|
||||||
"Access-Control-Allow-Origin": allowed_origin,
|
"Access-Control-Allow-Origin": allowed_origin,
|
||||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||||
|
|||||||
127
src/App.jsx
127
src/App.jsx
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react';
|
|||||||
const REDIRECT_HOST = 'luna.reversed.dev';
|
const REDIRECT_HOST = 'luna.reversed.dev';
|
||||||
const REDIRECT_TARGET = 'https://luna.spaceistyping.com';
|
const REDIRECT_TARGET = 'https://luna.spaceistyping.com';
|
||||||
const REDIRECT_DELAY_SECONDS = 5;
|
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 = [
|
const capabilities = [
|
||||||
{
|
{
|
||||||
@@ -49,8 +50,8 @@ const principles = [
|
|||||||
const contactCards = [
|
const contactCards = [
|
||||||
{
|
{
|
||||||
label: 'email',
|
label: 'email',
|
||||||
value: 'space@reversed.dev',
|
value: 'clawy@reversed.dev',
|
||||||
href: 'mailto:space@reversed.dev',
|
href: 'mailto:clawy@reversed.dev',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'gitea',
|
label: 'gitea',
|
||||||
@@ -59,8 +60,17 @@ const contactCards = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const initialForm = {
|
||||||
|
username: '',
|
||||||
|
email: '',
|
||||||
|
message: '',
|
||||||
|
};
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [secondsLeft, setSecondsLeft] = useState(REDIRECT_DELAY_SECONDS);
|
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;
|
const shouldRedirect = typeof window !== 'undefined' && window.location.hostname === REDIRECT_HOST;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -84,6 +94,47 @@ function App() {
|
|||||||
};
|
};
|
||||||
}, [shouldRedirect]);
|
}, [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 (
|
return (
|
||||||
<div className="site-shell">
|
<div className="site-shell">
|
||||||
{shouldRedirect && (
|
{shouldRedirect && (
|
||||||
@@ -217,12 +268,13 @@ function App() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="contact" className="section">
|
<section id="contact" className="section">
|
||||||
<div className="contact card glass">
|
<div className="contact card glass contact-layout">
|
||||||
|
<div className="contact-intro">
|
||||||
<div className="section-heading narrow left">
|
<div className="section-heading narrow left">
|
||||||
<p className="kicker">contact</p>
|
<p className="kicker">contact</p>
|
||||||
<h2>want to reach me?</h2>
|
<h2>want to reach me?</h2>
|
||||||
<p>
|
<p>
|
||||||
easiest route is still a direct message, but these work too.
|
send a message straight from the site, or just email me if that’s easier.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -235,6 +287,73 @@ function App() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<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>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
105
src/index.css
105
src/index.css
@@ -470,6 +470,111 @@ h1 {
|
|||||||
line-height: 1.5;
|
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) {
|
@media (max-width: 960px) {
|
||||||
.hero,
|
.hero,
|
||||||
.capability-grid,
|
.capability-grid,
|
||||||
|
|||||||
@@ -5,6 +5,6 @@ import tailwindcss from '@tailwindcss/vite'
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
preview: {
|
preview: {
|
||||||
allowedHosts: ['luna.reversed.dev'],
|
allowedHosts: ['luna.reversed.dev', 'luna.spaceistyping.com'],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user