Compare commits

...

8 Commits

  1. 3710
      pnpm-lock.yaml
  2. 10
      src/AppContext.ts
  3. 56
      src/components/AnimateEntry.tsx
  4. 81
      src/components/ButtonOrAnchor.tsx
  5. 287
      src/components/ContactForm.tsx
  6. 105
      src/components/Container.tsx
  7. 1
      src/components/Flippable.tsx
  8. 7
      src/components/Menu.tsx
  9. 28
      src/draggable.attempts/6/Draggable.ts
  10. 8
      src/draggable.attempts/6/index.html
  11. 2
      src/index.css
  12. 41
      src/index.tsx
  13. 30
      src/pages/main/Contact.tsx
  14. 29
      src/pages/main/Exp.tsx
  15. 90
      src/pages/main/Home.tsx
  16. 15
      src/pages/main/Work.tsx
  17. 26
      src/util/index.ts

3710
pnpm-lock.yaml

File diff suppressed because it is too large

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

56
src/components/AnimateEntry.tsx

@ -1,6 +1,25 @@
import React, { forwardRef } from "react";
import { css, cx } from "@emotion/css";
const animationStyle = css`
& > * {
animation: slideIn 300ms backwards;
}
@keyframes slideIn {
from {
opacity: 0;
translate: 0 3rem;
}
to {
opacity: 1;
translate: 0 0;
}
}
`;
const delayArray = Array.from({ length: 20 }, (_, i) => i);
export interface AnimateEntryProps extends React.HTMLAttributes<any> {
as?: React.ElementType;
delay?: number;
@ -14,34 +33,17 @@ export const AnimateEntry = forwardRef<HTMLDivElement, AnimateEntryProps>(
return (
<Component
className={cx(
css`
& > * {
animation: slideIn 300ms backwards;
}
${React.Children.map(
children,
(child, i) =>
child &&
css`
& > *:nth-child(${i + 1}) {
animation-delay: ${i * delay}ms;
}
`,
)}
@keyframes slideIn {
from {
opacity: 0;
translate: 0 3rem;
}
to {
opacity: 1;
translate: 0 0;
}
}
`,
"animate-entry",
animationStyle,
className,
delayArray.map(
i =>
css`
& > *:nth-child(${i + 1}) {
animation-delay: ${i * delay}ms;
}
`,
),
)}
{...props}
ref={ref}>

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;

105
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 { get, getTimeout } from "../util";
import Menu, { MenuEntries } from "./Menu";
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,33 +28,7 @@ const Container: React.FC<{
const containerChild = useRef<HTMLDivElement>(null);
const nextBtn = useRef<HTMLButtonElement>(null);
const [showMenu, setShowMenu] = useState(false);
useEffect(() => {
// scroll back to top when new page is loaded
window.scrollTo({ top: 0 });
if (highlightCircle.current) {
highlightCircle.current.classList.add("highlight");
timer(
() =>
highlightCircle.current &&
highlightCircle.current.classList.remove("highlight"),
1500,
);
}
if (nextBtn.current) {
nextBtn.current.style.width = "4rem";
timer(
() => nextBtn.current && (nextBtn.current.style.right = "10vw"),
300,
);
}
// cleanup
return clear;
}, []);
const context = useContext(AppContext);
const handleResize = () => {
if (containerChild.current && logoContainer.current)
@ -86,44 +62,65 @@ const Container: React.FC<{
} catch {}
};
const current = MenuEntries.findIndex(
([, path]) => location === path || location.startsWith(path + "/"),
);
const next = get.next(MenuEntries, current)[1];
const prev = get.prev(MenuEntries, current)[1];
const end = current === MenuEntries.length - 1;
const handlePrev = (e: MouseKb) => {
animateArrow(e);
timer(() => prev && navigate(prev), 300);
const current = MenuPaths.findIndex(path => location === path);
const index = (current - 1) % MenuPaths.length;
const prev = MenuPaths.at(index)!;
timer(() => navigate(prev), 300);
};
const handleNext = (e: MouseKb) => {
animateArrow(e);
timer(() => next && navigate(next), 300);
const current = MenuPaths.findIndex(path => location === path);
const index = (current + 1) % MenuPaths.length;
const next = MenuPaths.at(index)!;
timer(() => navigate(next), 300);
};
function kbnav(e: KeyboardEvent) {
if (context.contact.on) return;
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
switch (e.key) {
case "ArrowLeft":
return handlePrev(e);
case "ArrowRight":
return handleNext(e);
}
if (e.key === "ArrowLeft") return handlePrev(e);
else if (e.key === "ArrowRight") return handleNext(e);
}
useEffect(() => {
// scroll back to top when new page is loaded
window.scrollTo({ top: 0 });
if (highlightCircle.current) {
highlightCircle.current.classList.add("highlight");
timer(
() =>
highlightCircle.current &&
highlightCircle.current.classList.remove("highlight"),
1500,
);
}
if (nextBtn.current) {
nextBtn.current.style.width = "3rem";
timer(
() => nextBtn.current && (nextBtn.current.style.right = "10vw"),
300,
);
}
window.addEventListener("keydown", kbnav);
// cleanup
return () => window.removeEventListener("keydown", kbnav);
}, []);
return () => (window.removeEventListener("keydown", kbnav), clear());
}, [location, context.contact.on]);
// on first render
useLayoutEffect(handleResize, []);
const end = location === MenuEntries[MenuEntries.length - 1][1];
return (
<div
className={css`
@ -138,6 +135,7 @@ const Container: React.FC<{
min-height: 100vh;
position: relative;
`}>
<ContactForm toggle={context.contact} />
<div
aria-hidden
className={cx(
@ -154,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;
`,
)}
@ -175,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}
@ -240,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
@ -254,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;
@ -287,6 +285,7 @@ const Container: React.FC<{
<AnimateEntry
delay={delay}
className={cx(
"container",
css`
width: 100%;
max-width: 62rem;

1
src/components/Flippable.tsx

@ -53,7 +53,6 @@ export const Flippable: React.FC<FlippableProps> = ({
width: 100%;
height: 100%;
transform-style: preserve-3d;
cursor: pointer;
transition: rotate 0.6s cubic-bezier(0.4, 0, 0.2, 1);
rotate: ${isFlipped ? "y 180deg" : "y 0deg"};

7
src/components/Menu.tsx

@ -12,6 +12,7 @@ export const MENU = {
} as const;
export const MenuEntries = Object.entries(MENU);
export const MenuPaths = Object.values(MENU);
const desktopNav = css`
float: right;
@ -88,7 +89,11 @@ const Menu: React.FC<{
<a
className={cx({ active })}
key={link}
onClick={e => (setShowMenu(false), navigate(link)(e))}
onClick={e => (
setShowMenu(false),
navigate(link)(e),
(e.target as HTMLAnchorElement).blur()
)}
href={link}>
{name}
</a>

28
src/draggable.attempts/6/Draggable.ts

@ -88,6 +88,7 @@ export function makeDraggable(card: HTMLElement, opts: DraggableOpts = {}) {
state.dragging = true;
activePointerId = e.pointerId;
card.style.cursor = "grabbing";
document.body.style.cursor = "grabbing";
velocity = { x: 0, y: 0 };
const dx = e.pageX - center.x;
@ -153,6 +154,7 @@ export function makeDraggable(card: HTMLElement, opts: DraggableOpts = {}) {
const cancel = () => {
state.dragging = false;
activePointerId = null;
document.body.style.cursor = "";
card.style.cursor = "grab";
document.body.style.userSelect = "auto";
document.body.style.webkitUserSelect = "auto";
@ -246,27 +248,25 @@ export function makeDraggable(card: HTMLElement, opts: DraggableOpts = {}) {
card.style.touchAction = "none";
card.style.transform = `translate(0px, 0px) rotate(${rotation}rad)`;
card.style.willChange = "transform";
card.addEventListener("pointerdown", down, { passive: true });
window.addEventListener("pointermove", move, { passive: true });
window.addEventListener("pointerup", up, { passive: true });
window.addEventListener("pointercancel", up, { passive: true });
window.addEventListener("blur", up, { passive: true });
window.addEventListener("keydown", up, { passive: true });
const abort = new AbortController();
const evtOpts = { passive: true, signal: abort.signal };
card.addEventListener("pointerdown", down, evtOpts);
window.addEventListener("pointermove", move, evtOpts);
window.addEventListener("pointerup", up, evtOpts);
window.addEventListener("pointercancel", up, evtOpts);
window.addEventListener("blur", up, evtOpts);
window.addEventListener("keydown", up, evtOpts);
window.addEventListener("resize", handleResize);
render();
return function cleanup() {
abort.abort();
if (animationFrameId !== null) cancelAnimationFrame(animationFrameId);
card.removeEventListener("pointerdown", down);
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
window.removeEventListener("pointercancel", up);
window.removeEventListener("blur", up);
window.removeEventListener("keydown", up);
window.removeEventListener("resize", handleResize);
card.style.cursor = "";
document.body.style.cursor = "";
card.style.touchAction = "";
card.style.userSelect = "";
};

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

41
src/index.tsx

@ -2,7 +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"));
@ -21,21 +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>
<Suspense>
<App />
</Suspense>
<ContextApp />
</React.StrictMode>,
);

30
src/pages/main/Contact.tsx

@ -1,12 +1,12 @@
import React from "react";
import React, { useContext } from "react";
import { css } from "@emotion/css";
import { useEffect, useState } from "react";
import Container from "../../components/Container";
import { setupCursorTracking } from "../../util/index.ts";
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;
@ -55,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);
@ -96,11 +109,16 @@ const Contact: React.FC = () => {
}, []);
return (
<Container>
<>
<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>
<p>Great. You've distributed all the cards!</p>
<p>What now?</p>
<br />
<a href="/">Start over?</a>
@ -111,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;
`}>
@ -246,7 +264,7 @@ const Contact: React.FC = () => {
</Draggable>
))}
</AnimateEntry>
</Container>
</>
);
};

29
src/pages/main/Exp.tsx

@ -1,12 +1,12 @@
import React, { useEffect } from "react";
import React, { useEffect, useState } from "react";
import { css, cx } from "@emotion/css";
import Container from "../../components/Container";
import { ExpUnit } from "../../components/Exp/Unit";
import { age, experience } from "./data/experience";
import { offscreenWidth } from "../../components/constants";
import { Tags } from "../../components/Exp/Tags";
import useSearchParams from "../../util/useSearchParams";
import useLocation from "wouter/use-location";
import { AnimateEntry } from "../../components/AnimateEntry";
const exp_route = /^\/experience\/?[^\/]*$/;
const slug_replace = /^\/experience\/?/;
@ -14,6 +14,11 @@ const slug_replace = /^\/experience\/?/;
const Exp: React.FC = () => {
const [location, navigate] = useLocation();
const tags = useSearchParams("tag");
const [entryDelay, setEntryDelay] = useState(true);
useEffect(() => {
if (tags.size) setEntryDelay(false);
}, [tags]);
if (!exp_route.test(location)) {
navigate("/experience", { replace: true });
@ -36,7 +41,7 @@ const Exp: React.FC = () => {
}, []);
return (
<Container>
<>
<h2>
Im a {age} year old developer from
<br />
@ -52,13 +57,13 @@ const Exp: React.FC = () => {
</span>
:
</p>
<section>
<Tags
tags={["Programming", "Design", "Architecture", "Writing"]}
selected={tags}
/>
</section>
<div
<Tags
tags={["Programming", "Design", "Architecture", "Writing"]}
selected={tags}
/>
<AnimateEntry
as="section"
delay={entryDelay ? 150 : 0}
className={cx(
css`
width: 100%;
@ -93,8 +98,8 @@ const Exp: React.FC = () => {
}}
/>
))}
</div>
</Container>
</AnimateEntry>
</>
);
};

90
src/pages/main/Home.tsx

@ -1,16 +1,25 @@
import React from "react";
import Container from "../../components/Container";
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 { 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 (
<Container
<AnimateEntry
as="main"
delay={200}
className={css`
--gap: 2.2rem;
display: flex;
flex-direction: column;
gap: var(--gap);
`}>
<section>
<h1
@ -121,41 +130,52 @@ 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;
position: relative;
z-index: 0;
display: flex;
gap: 0.8rem;
flex-wrap: wrap;
margin-top: 0.8rem;
& a {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.6rem 0.9rem;
& > * {
min-width: fit-content;
}
`}>
<Button
className={css`
width: fit-content;
z-index: 0;
& a:hover {
color: inherit;
}
`}
ref={setupCursorTracking}>
<div className="dynamic-gradient" />
<a href="https://mkr.pw/resume" target="_blank">
Download my resume
<Arrow />
</a>
</button>
& a {
display: flex;
align-items: center;
gap: 1rem;
width: 100%;
height: 100%;
min-width: max-content;
}
& a:hover {
color: inherit;
}
`}
ref={setupCursorTracking}>
<div className="dynamic-gradient" />
<a href="https://mkr.pw/resume" target="_blank">
Download my resume
<Arrow />
</a>
</Button>
<Button
styled="anchor"
className="contact-button"
onClick={() => context.contact.set(true)}>
Let's talk!
</Button>
</div>
</article>
</main>
</Container>
</AnimateEntry>
);
};

15
src/pages/main/Work.tsx

@ -1,7 +1,7 @@
import React from "react";
import { css, cx } from "@emotion/css";
import Container from "../../components/Container";
import { otherProjects, projects, type Project } from "./data/project";
import { AnimateEntry } from "../../components/AnimateEntry";
const styles = {
project: css`
@ -138,10 +138,11 @@ const otherProjectsStyle = css`
const Exp: React.FC = () => {
return (
<Container>
<>
<h2>Things I've built</h2>
<p>A few projects I'm proud of:</p>
<div
<AnimateEntry
as="section"
className={css`
display: grid;
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
@ -151,7 +152,7 @@ const Exp: React.FC = () => {
{projects.map(unit => (
<ProjectUnit {...unit} key={unit.title} />
))}
</div>
</AnimateEntry>
<hr />
<p>
Apart from the above, I've also built some other interesting stuff over
@ -164,7 +165,7 @@ const Exp: React.FC = () => {
<th>Description</th>
</tr>
</thead>
<tbody>
<AnimateEntry as="tbody">
{otherProjects.map(unit => (
<tr key={unit.title}>
<td
@ -201,9 +202,9 @@ const Exp: React.FC = () => {
</td>
</tr>
))}
</tbody>
</AnimateEntry>
</table>
</Container>
</>
);
};

26
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));
@ -123,15 +123,6 @@ export function comparePaths(p1: string, p2: string) {
return normalise(p1) === normalise(p2);
}
export const get = {
next<X>(xs: X[], i: number) {
return xs.at((i + 1) % xs.length)!;
},
prev<X>(xs: X[], i: number) {
return xs.at((i - 1) % xs.length)!;
},
};
// required css is inlined in index.html
export function setupCursorTracking(el: HTMLElement | null) {
if (!el) return;
@ -156,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