Browse Source

feat: contact form

master
Muthu Kumar 1 month ago
parent
commit
8e1c406a00
Failed to extract signature
  1. 10
      src/AppContext.ts
  2. 81
      src/components/ButtonOrAnchor.tsx
  3. 287
      src/components/ContactForm.tsx
  4. 24
      src/components/Container.tsx
  5. 8
      src/draggable.attempts/6/index.html
  6. 2
      src/index.css
  7. 42
      src/index.tsx
  8. 23
      src/pages/main/Contact.tsx
  9. 49
      src/pages/main/Home.tsx
  10. 17
      src/util/index.ts

10
src/AppContext.ts

@ -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);

81
src/components/ButtonOrAnchor.tsx

@ -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>
);
},
);

287
src/components/ContactForm.tsx

@ -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;

24
src/components/Container.tsx

@ -1,13 +1,15 @@
import React, { useState, useEffect, useRef, useLayoutEffect } from "react";
import React, { useEffect, useRef, useLayoutEffect, useContext } from "react";
import { css, cx } from "@emotion/css";
import useLocation from "wouter/use-location";
import { ReactComponent as Logo } from "../assets/logo.svg";
import { ReactComponent as Right } from "../assets/arrow-right.svg";
import { getTimeout } from "../util";
import { getTimeout, useToggle } from "../util";
import Menu, { MenuEntries, MenuPaths } from "./Menu";
import useMediaQuery from "../util/useMediaQuery";
import { AnimateEntry } from "./AnimateEntry";
import ContactForm from "./ContactForm";
import { AppContext } from "../AppContext";
const [timer, clear] = getTimeout();
@ -26,7 +28,7 @@ const Container: React.FC<{
const containerChild = useRef<HTMLDivElement>(null);
const nextBtn = useRef<HTMLButtonElement>(null);
const [showMenu, setShowMenu] = useState(false);
const context = useContext(AppContext);
const handleResize = () => {
if (containerChild.current && logoContainer.current)
@ -79,6 +81,7 @@ const Container: React.FC<{
};
function kbnav(e: KeyboardEvent) {
if (context.contact.on) return;
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
if (e.key === "ArrowLeft") return handlePrev(e);
@ -111,7 +114,7 @@ const Container: React.FC<{
// cleanup
return () => (window.removeEventListener("keydown", kbnav), clear());
}, [location]);
}, [location, context.contact.on]);
// on first render
useLayoutEffect(handleResize, []);
@ -132,6 +135,7 @@ const Container: React.FC<{
min-height: 100vh;
position: relative;
`}>
<ContactForm toggle={context.contact} />
<div
aria-hidden
className={cx(
@ -148,7 +152,7 @@ const Container: React.FC<{
rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 1) 100%
);
z-index: 1000;
z-index: 800;
pointer-events: none;
`,
)}
@ -169,8 +173,8 @@ const Container: React.FC<{
border: 0;
font-size: 1rem;
`}
onMouseOver={() => !mobile && setShowMenu(true)}
onMouseOut={() => !mobile && setShowMenu(false)}>
onMouseOver={() => !mobile && context.menu.set(true)}
onMouseOut={() => !mobile && context.menu.set(false)}>
<button
aria-label={mobile ? "Tap to show menu" : "Back to home"}
ref={highlightCircle}
@ -234,10 +238,10 @@ const Container: React.FC<{
)}>
<Logo
viewBox="0 0 264 264"
onClick={() => (mobile ? setShowMenu(true) : navigate("/"))}
onClick={() => (mobile ? context.menu.set(true) : navigate("/"))}
/>
</button>
<Menu show={showMenu} setShowMenu={setShowMenu} />
<Menu show={context.menu.on} setShowMenu={context.menu.set} />
</span>
)}
<button
@ -248,7 +252,7 @@ const Container: React.FC<{
position: fixed;
right: 14vw;
bottom: 10vh;
z-index: 500;
z-index: 900;
background: none;
padding: 0;
font-weight: 500;

8
src/draggable.attempts/6/index.html

@ -20,8 +20,8 @@
overflow: hidden;
}
#card {
width: 24rem;
height: 16rem;
width: 20rem;
height: 12rem;
background: linear-gradient(135deg, #ff9a9e, #fad0c4);
border-radius: 20px;
position: absolute;
@ -38,7 +38,7 @@
display: flex;
width: 100%;
height: 100%;
padding: 4rem;
padding: 2rem;
overflow: hidden;
}
@ -58,7 +58,7 @@
</head>
<body>
<div class="container">
<div class="sidebar"></div>
<!-- <div class="sidebar"></div> -->
<main>
<div id="card"></div>
</main>

2
src/index.css

@ -6,6 +6,8 @@
--primary-colour: rgb(255, 85, 85);
--text-colour: rgb(210, 210, 210);
--text-subdued: rgb(150, 150, 150);
--text-success: rgb(100, 255, 100);
--text-error: rgb(255, 100, 100);
--table-border: rgb(54, 54, 54);
--card-active: rgb(45, 45, 45);
--card-active-border: rgb(60, 60, 60);

42
src/index.tsx

@ -2,8 +2,9 @@ import React, { lazy, Suspense } from "react";
import { createRoot } from "react-dom/client";
import useLocation from "wouter/use-location";
import { normalise } from "./util";
import { normalise, useToggle } from "./util";
import Container from "./components/Container";
import { AppContext } from "./AppContext";
const Home = lazy(() => import("./pages/main/Home"));
const Exp = lazy(() => import("./pages/main/Exp"));
@ -22,23 +23,40 @@ function App() {
return null;
}
if (normalised === "/") return <Home />;
if (normalised === "/experience") return <Exp />;
if (normalised.startsWith("/experience/")) return <Exp />;
if (normalised === "/work") return <Work />;
if (normalised === "/contact") return <Contact />;
let child: React.ReactNode;
if (normalised === "/") child = <Home />;
else if (normalised === "/experience") child = <Exp />;
else if (normalised.startsWith("/experience/")) child = <Exp />;
else if (normalised === "/work") child = <Work />;
else if (normalised === "/contact") child = <Contact />;
// if (normalised === "/live") return <Live />;
// if (normalised === "/blog") return <BlogHome />;
// if (location.startsWith("/blog")) return <BlogPost />;
return <NotFound />;
else child = <NotFound />;
return (
<Container>
<Suspense>{child}</Suspense>
</Container>
);
}
const ContextApp = () => {
const context: AppContext = {
menu: useToggle(false),
contact: useToggle(false),
};
return (
<AppContext.Provider value={context}>
<App />
</AppContext.Provider>
);
};
createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<Container>
<Suspense>
<App />
</Suspense>
</Container>
<ContextApp />
</React.StrictMode>,
);

23
src/pages/main/Contact.tsx

@ -1,4 +1,4 @@
import React from "react";
import React, { useContext } from "react";
import { css } from "@emotion/css";
import { useEffect, useState } from "react";
import { setupCursorTracking } from "../../util/index.ts";
@ -6,6 +6,7 @@ import { ReactComponent as Logo } from "../../assets/logo.svg";
import { Flippable } from "../../components/Flippable.tsx";
import { AnimateEntry } from "../../components/AnimateEntry.tsx";
import { Draggable } from "../../draggable.attempts/6/Draggable2.tsx";
import { AppContext } from "../../AppContext.ts";
const A = css`
text-decoration: none;
@ -54,7 +55,20 @@ const contactCards = Array.from({ length: CARD_COUNT }, () => {
return rotation;
});
const formButtonStyle = css`
background: var(--card-tags);
border: 0;
border-radius: 0.5rem;
width: fit-content;
padding: 0.6rem 0.9rem;
color: var(--text-colour);
font-size: 1rem;
font-weight: 600;
`;
const Contact: React.FC = () => {
const context = useContext(AppContext);
const [contact, setContact] = useState<Contact>(CONTACT);
const [visible, setVisible] = useState(contactCards.length);
@ -97,6 +111,11 @@ const Contact: React.FC = () => {
return (
<>
<h1>MKRhere</h1>
<button
className={formButtonStyle}
onClick={() => context.contact.toggle()}>
Prefer a contact form?
</button>
{visible < 1 && (
<AnimateEntry as="article" delay={500}>
<p>Great. You've distributed all the cards!</p>
@ -110,7 +129,7 @@ const Contact: React.FC = () => {
delay={200}
className={css`
width: 100%;
min-height: max(40vh, 11rem);
min-height: max(30vh, 11rem);
height: 100%;
position: relative;
`}>

49
src/pages/main/Home.tsx

@ -1,12 +1,16 @@
import React from "react";
import FlickerList, { Tooltip } from "../../components/FlickerList";
import React, { useContext } from "react";
import FlickerList, { Tooltip } from "../../components/FlickerList.tsx";
import { ReactComponent as Arrow } from "../../assets/arrow-thin.svg";
import { css } from "@emotion/css";
import { setupCursorTracking } from "../../util";
import { Emoji } from "../../components/Emoji";
import { AnimateEntry } from "../../components/AnimateEntry";
import { setupCursorTracking } from "../../util/index.ts";
import { Emoji } from "../../components/Emoji.tsx";
import { AnimateEntry } from "../../components/AnimateEntry.tsx";
import { AppContext } from "../../AppContext.ts";
import { Button } from "../../components/ButtonOrAnchor.tsx";
const Home: React.FC = () => {
const context = useContext(AppContext);
return (
<AnimateEntry
as="main"
@ -126,25 +130,29 @@ const Home: React.FC = () => {
I'm looking for work!
<Emoji emoji="tada" baseline />
</p>
<button
<div
className={css`
background: var(--card-tags);
border: 0;
border-radius: 0.5rem;
width: fit-content;
color: var(--text-colour);
cursor: pointer;
font-size: 1rem;
margin-top: 0.4rem;
display: flex;
gap: 0.8rem;
flex-wrap: wrap;
margin-top: 0.8rem;
position: relative;
& > * {
min-width: fit-content;
}
`}>
<Button
className={css`
width: fit-content;
z-index: 0;
& a {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.6rem 0.9rem;
width: 100%;
height: 100%;
min-width: max-content;
}
& a:hover {
@ -157,7 +165,14 @@ const Home: React.FC = () => {
Download my resume
<Arrow />
</a>
</button>
</Button>
<Button
styled="anchor"
className="contact-button"
onClick={() => context.contact.set(true)}>
Let's talk!
</Button>
</div>
</article>
</main>
</AnimateEntry>

17
src/util/index.ts

@ -1,4 +1,4 @@
import React from "react";
import React, { useState } from "react";
import useLocation from "wouter/use-location";
export const sleep = (t: number) => new Promise(r => setTimeout(r, t));
@ -147,3 +147,18 @@ export function normaliseAngleDifference(delta: number): number {
else if (delta <= -Math.PI) delta += 2 * Math.PI;
return delta;
}
export interface Toggle {
on: boolean;
toggle: () => void;
set: (value: boolean) => void;
}
export function useToggle(initial: boolean): Toggle {
const [state, setState] = useState(initial);
return {
on: state,
toggle: () => setState(s => !s),
set: (value: boolean) => setState(value),
};
}

Loading…
Cancel
Save