mirror of https://github.com/mkrhere/pw2
10 changed files with 513 additions and 62 deletions
@ -0,0 +1,10 @@ |
|||
import { Toggle } from "./util"; |
|||
import { createContext } from "react"; |
|||
|
|||
export interface AppContext { |
|||
menu: Toggle; |
|||
contact: Toggle; |
|||
} |
|||
|
|||
// state will be set upon initialisation
|
|||
export const AppContext = createContext<AppContext>({} as AppContext); |
@ -0,0 +1,81 @@ |
|||
import React, { forwardRef } from "react"; |
|||
import { css, cx } from "@emotion/css"; |
|||
|
|||
const anchorStyle = css` |
|||
display: inline; |
|||
color: var(--text-colour); |
|||
font-weight: 800; |
|||
background: none; |
|||
border: none; |
|||
cursor: pointer; |
|||
font-size: 1rem; |
|||
|
|||
&:hover { |
|||
color: var(--primary-colour); |
|||
} |
|||
`;
|
|||
|
|||
const buttonStyle = css` |
|||
display: inline; |
|||
background: var(--card-tags); |
|||
border: 0; |
|||
border-radius: 0.5rem; |
|||
color: var(--text-colour); |
|||
|
|||
cursor: pointer; |
|||
font-size: 1rem; |
|||
text-align: center; |
|||
|
|||
gap: 1rem; |
|||
padding: 0.8rem 1.25rem; |
|||
width: 100%; |
|||
|
|||
position: relative; |
|||
|
|||
&:disabled { |
|||
background-color: var(--bg-colour); |
|||
cursor: not-allowed; |
|||
} |
|||
`;
|
|||
|
|||
export interface ButtonProps |
|||
extends React.ButtonHTMLAttributes<HTMLButtonElement> { |
|||
styled?: "button" | "anchor"; |
|||
} |
|||
|
|||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>( |
|||
({ children, className, ...props }, ref) => { |
|||
return ( |
|||
<button |
|||
className={cx( |
|||
props.styled === "anchor" ? anchorStyle : buttonStyle, |
|||
className, |
|||
)} |
|||
{...props} |
|||
ref={ref}> |
|||
{children} |
|||
</button> |
|||
); |
|||
}, |
|||
); |
|||
|
|||
export interface AnchorProps |
|||
extends React.AnchorHTMLAttributes<HTMLAnchorElement> { |
|||
styled?: "button" | "anchor"; |
|||
} |
|||
|
|||
export const Anchor = forwardRef<HTMLAnchorElement, AnchorProps>( |
|||
({ children, className, ...props }, ref) => { |
|||
return ( |
|||
<a |
|||
className={cx( |
|||
props.styled === "button" ? buttonStyle : anchorStyle, |
|||
className, |
|||
)} |
|||
{...props} |
|||
ref={ref}> |
|||
{children} |
|||
</a> |
|||
); |
|||
}, |
|||
); |
@ -0,0 +1,287 @@ |
|||
import React, { useEffect, useRef, useState } from "react"; |
|||
import { css, cx } from "@emotion/css"; |
|||
import { setupCursorTracking, Toggle } from "../util"; |
|||
|
|||
import { ReactComponent as CloseIcon } from "../assets/cross.svg"; |
|||
import { Button } from "./ButtonOrAnchor"; |
|||
|
|||
const containerStyle = css` |
|||
width: 100%; |
|||
height: 100%; |
|||
position: fixed; |
|||
top: 0; |
|||
left: 0; |
|||
z-index: 1000; |
|||
background-color: rgba(from var(--card-bg) r g b / 0.9); |
|||
transition: opacity 300ms; |
|||
|
|||
& main { |
|||
position: absolute; |
|||
top: 0; |
|||
right: 0; |
|||
width: 50rem; |
|||
max-width: 90vw; |
|||
height: 100%; |
|||
background-color: var(--bg-colour); |
|||
translate: 100% 0; |
|||
transition: translate 300ms, opacity 300ms; |
|||
display: grid; |
|||
place-items: center; |
|||
overflow-y: auto; |
|||
|
|||
& > .contact-container { |
|||
display: flex; |
|||
flex-direction: column; |
|||
padding: 2rem; |
|||
gap: 2rem; |
|||
width: 100%; |
|||
max-width: 35rem; |
|||
|
|||
& > .contact-header { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
|
|||
& h3 { |
|||
font-size: 1.5rem; |
|||
font-weight: 700; |
|||
} |
|||
|
|||
& .close-button { |
|||
background: none; |
|||
border: none; |
|||
cursor: pointer; |
|||
color: var(--text-subdued); |
|||
|
|||
&:hover, |
|||
&:focus { |
|||
color: var(--text-colour); |
|||
} |
|||
} |
|||
} |
|||
|
|||
& form { |
|||
display: flex; |
|||
flex-direction: column; |
|||
gap: 1rem; |
|||
} |
|||
|
|||
& article { |
|||
color: var(--text-subdued); |
|||
margin-bottom: 1rem; |
|||
display: flex; |
|||
flex-direction: column; |
|||
gap: 1.2rem; |
|||
font-size: 0.9rem; |
|||
} |
|||
|
|||
& .success { |
|||
color: var(--text-success); |
|||
} |
|||
|
|||
& .error { |
|||
color: var(--text-error); |
|||
} |
|||
} |
|||
} |
|||
`;
|
|||
|
|||
const inputStyle = css` |
|||
width: 100%; |
|||
padding: 0.8rem; |
|||
border: 1px solid var(--table-border); |
|||
background-color: var(--card-bg); |
|||
color: var(--text-colour); |
|||
font-size: 1rem; |
|||
border-radius: 0.5rem; |
|||
|
|||
textarea& { |
|||
height: 100%; |
|||
max-height: 20vh; |
|||
} |
|||
|
|||
&:disabled { |
|||
opacity: 0.8; |
|||
cursor: not-allowed; |
|||
} |
|||
`;
|
|||
|
|||
const ContactForm: React.FC<{ |
|||
toggle: Toggle; |
|||
}> = ({ toggle }) => { |
|||
const nameRef = useRef<HTMLInputElement>(null); |
|||
const [sending, setSending] = useState(false); |
|||
const [sent, setSent] = useState(false); |
|||
|
|||
useEffect(() => { |
|||
if (nameRef.current && toggle.on) nameRef.current.focus(); |
|||
|
|||
if (!toggle.on) { |
|||
setSent(false); |
|||
setSending(false); |
|||
setError(null); |
|||
} |
|||
|
|||
// remember current page
|
|||
const href = window.location.href; |
|||
|
|||
// hijack back button and close the form instead
|
|||
const handleBack = (e: PopStateEvent) => { |
|||
if (toggle.on) { |
|||
e.preventDefault(); |
|||
toggle.set(false); |
|||
// not sure of a cleaner way to do this
|
|||
// this resets the history to the current page
|
|||
window.history.replaceState(null, "", href); |
|||
} |
|||
}; |
|||
|
|||
const handleEscape = (e: KeyboardEvent) => { |
|||
if (e.key === "Escape") toggle.set(false); |
|||
}; |
|||
|
|||
window.addEventListener("keydown", handleEscape); |
|||
window.addEventListener("popstate", handleBack); |
|||
return () => { |
|||
window.removeEventListener("keydown", handleEscape); |
|||
window.removeEventListener("popstate", handleBack); |
|||
}; |
|||
}, [toggle.on]); |
|||
|
|||
const [name, setName] = useState(""); |
|||
const [email, setEmail] = useState(""); |
|||
const [message, setMessage] = useState(""); |
|||
const [error, setError] = useState<string | null>(null); |
|||
|
|||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { |
|||
e.preventDefault(); |
|||
|
|||
if (!name || !email || !message) { |
|||
return setError("Please fill in all fields."); |
|||
} |
|||
|
|||
setError(null); |
|||
setSending(true); |
|||
try { |
|||
await fetch("https://api.feathers.studio/send", { |
|||
method: "POST", |
|||
body: JSON.stringify({ |
|||
type: "website-contact", |
|||
name, |
|||
email, |
|||
message, |
|||
}), |
|||
}); |
|||
setName(""); |
|||
setEmail(""); |
|||
setMessage(""); |
|||
setSending(false); |
|||
setSent(true); |
|||
|
|||
// reset if the form was closed before the request was sent
|
|||
if (!toggle.on) setSent(false); |
|||
} catch (e) { |
|||
console.error(e); |
|||
setError( |
|||
"Failed to send message. Try again later, or notify me via email or Telegram, thanks!", |
|||
); |
|||
setSending(false); |
|||
setSent(false); |
|||
} |
|||
}; |
|||
|
|||
const disabled = sending || sent; |
|||
|
|||
return ( |
|||
<section |
|||
aria-hidden={!toggle.on} |
|||
onClick={() => toggle.set(false)} |
|||
className={cx( |
|||
containerStyle, |
|||
css` |
|||
opacity: ${toggle.on ? 1 : 0}; |
|||
pointer-events: ${toggle.on ? "auto" : "none"}; |
|||
`,
|
|||
)}> |
|||
<main |
|||
onClick={e => e.stopPropagation()} |
|||
style={{ |
|||
translate: toggle.on ? "0 0" : "100% 0", |
|||
}}> |
|||
<div className="contact-container"> |
|||
<div className="contact-header"> |
|||
<h3>Let's talk!</h3> |
|||
<button className="close-button" onClick={() => toggle.set(false)}> |
|||
<CloseIcon /> |
|||
</button> |
|||
</div> |
|||
<form onSubmit={handleSubmit}> |
|||
<input |
|||
disabled={disabled} |
|||
ref={nameRef} |
|||
type="text" |
|||
name="name" |
|||
placeholder="Name" |
|||
value={name} |
|||
onChange={e => setName(e.target.value)} |
|||
className={inputStyle} |
|||
/> |
|||
<input |
|||
disabled={disabled} |
|||
type="email" |
|||
name="email" |
|||
placeholder="Email" |
|||
value={email} |
|||
onChange={e => setEmail(e.target.value)} |
|||
className={inputStyle} |
|||
/> |
|||
<textarea |
|||
disabled={disabled} |
|||
name="message" |
|||
placeholder="Message" |
|||
value={message} |
|||
onChange={e => setMessage(e.target.value)} |
|||
className={inputStyle} |
|||
/> |
|||
<Button disabled={disabled} type="submit" ref={setupCursorTracking}> |
|||
<div className="dynamic-gradient" /> |
|||
{sending ? "Sending..." : sent ? "Sent ✅" : "Send"} |
|||
</Button> |
|||
{sent && ( |
|||
<p className="success"> |
|||
I've received your message! I'll get back to you as soon as |
|||
possible. Thank you for reaching out! |
|||
</p> |
|||
)} |
|||
{error && <p className="error">{error}</p>} |
|||
</form> |
|||
<article> |
|||
<p> |
|||
Hey there! I live in{" "} |
|||
<a |
|||
href="https://www.worldtimebuddy.com/?pl=1&lid=1264527,2643743,5128581,5391959,1819729,1850147&h=1264527&hf=1" |
|||
target="_blank" |
|||
rel="noreferrer"> |
|||
Chennai, South India (UTC+05:30) |
|||
</a> |
|||
. |
|||
</p> |
|||
<p> |
|||
I usually respond within 24 hours. I'd love to hear about new |
|||
opportunities or collaborations. Or just drop by to say hi! |
|||
</p> |
|||
<p> |
|||
You can also contact me via{" "} |
|||
<a href="https://t.me/mkrhere" target="_blank" rel="noreferrer"> |
|||
Telegram/MKRhere |
|||
</a> |
|||
. |
|||
</p> |
|||
</article> |
|||
</div> |
|||
</main> |
|||
</section> |
|||
); |
|||
}; |
|||
|
|||
export default ContactForm; |
Loading…
Reference in new issue