repos

isaacbythewood.com-nextjs

mirror archived upstream

Animation-heavy personal portfolio on Next.js: a custom cursor, a page-transition loader, and canvas backgrounds.

canvas-animationscss-modulesdark-themehandcodednextjspersonal-websiteportfolioreact

8.6 KB · 277 lines · JavaScript Raw History
  1import React, { useState, useRef, useEffect } from "react";
  2import styles from "@styles/pages/contact.module.css";
  3import Image from "next/image";
  4
  5import Page from "../components/page";
  6
  7const CHAT_TREE = {
  8  start: {
  9    messages: ["Hey there!", "What brings you here?"],
 10    options: [
 11      { label: "I want to work together", next: "collab" },
 12      { label: "Just checking out your work", next: "browsing" },
 13      { label: "Job opportunity", next: "job" },
 14    ],
 15  },
 16  collab: {
 17    messages: [
 18      "Nice! I'm always open to side projects and open source collabs.",
 19      "Best way to reach me is email. Drop me a line and tell me what you're thinking.",
 20    ],
 21    options: [
 22      { label: "What's your email?", next: "email" },
 23      { label: "What kind of projects?", next: "projects" },
 24    ],
 25  },
 26  browsing: {
 27    messages: [
 28      "Welcome! Feel free to look around.",
 29      "If anything catches your eye or you want to chat, I'm easy to reach.",
 30    ],
 31    options: [
 32      { label: "How do I reach you?", next: "email" },
 33      { label: "What are you working on?", next: "projects" },
 34    ],
 35  },
 36  job: {
 37    messages: [
 38      "I appreciate the interest.",
 39      "I'm currently at Craftmaster Furniture as a Senior Solutions Architect and not actively looking.",
 40      "That said, feel free to reach out if it's compelling.",
 41    ],
 42    options: [
 43      { label: "What's the best way to reach you?", next: "email" },
 44      { label: "What do you do there?", next: "work" },
 45    ],
 46  },
 47  email: {
 48    messages: [
 49      "Email is best: [email protected]",
 50      "You can also find me on GitHub as /overshard or on Discord as Overshard#4907.",
 51    ],
 52    options: [{ label: "Thanks!", next: "end" }],
 53  },
 54  projects: {
 55    messages: [
 56      "Lately I've been deep into AI agent workflows, automated testing infrastructure, and tooling for fast release cycles.",
 57      "On the side I build self-hosted tools and experiment with whatever is new. Check out the Code page for more.",
 58    ],
 59    options: [
 60      { label: "How do I reach you?", next: "email" },
 61      { label: "Cool, thanks!", next: "end" },
 62    ],
 63  },
 64  work: {
 65    messages: [
 66      "I focus on AI agent workflows, automated integration testing, and building systems for rapid releases without sacrificing stability or security.",
 67      "Two decades of experience across the full stack, from kernel modules to regulated healthcare environments.",
 68    ],
 69    options: [
 70      { label: "How do I reach you?", next: "email" },
 71      { label: "Interesting, thanks!", next: "end" },
 72    ],
 73  },
 74  end: {
 75    messages: ["Anytime! Take care."],
 76    options: [],
 77  },
 78};
 79
 80const Contact = () => {
 81  const [chatHistory, setChatHistory] = useState([]);
 82  const [currentNode, setCurrentNode] = useState(null);
 83  const [typing, setTyping] = useState(false);
 84  const [messageQueue, setMessageQueue] = useState([]);
 85
 86  useEffect(() => {
 87    // Start the chat after a brief delay
 88    const timer = setTimeout(() => {
 89      queueMessages("start");
 90    }, 800);
 91    return () => clearTimeout(timer);
 92  }, []);
 93
 94  const processingRef = useRef(false);
 95
 96  useEffect(() => {
 97    if (messageQueue.length === 0 || processingRef.current) return;
 98    processingRef.current = true;
 99    setTyping(true);
100    const delay = 600 + messageQueue[0].length * 15;
101    const timer = setTimeout(() => {
102      setChatHistory((prev) => [
103        ...prev,
104        { type: "isaac", text: messageQueue[0] },
105      ]);
106      setMessageQueue((prev) => prev.slice(1));
107      setTyping(false);
108      processingRef.current = false;
109    }, delay);
110    return () => clearTimeout(timer);
111  }, [messageQueue]);
112
113  const chatContainerRef = useRef(null);
114
115  useEffect(() => {
116    if (chatContainerRef.current) {
117      chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight;
118    }
119  }, [chatHistory, typing]);
120
121  const queueMessages = (nodeKey) => {
122    const node = CHAT_TREE[nodeKey];
123    setCurrentNode(nodeKey);
124    setMessageQueue(node.messages);
125  };
126
127  const handleOption = (option) => {
128    setChatHistory((prev) => [
129      ...prev,
130      { type: "user", text: option.label },
131    ]);
132    if (option.next) {
133      setTimeout(() => queueMessages(option.next), 400);
134    }
135  };
136
137  const node = CHAT_TREE[currentNode];
138  const showOptions =
139    node &&
140    node.options.length > 0 &&
141    messageQueue.length === 0 &&
142    !typing &&
143    chatHistory.length > 0 &&
144    chatHistory[chatHistory.length - 1].type === "isaac";
145
146  return (
147    <Page
148      title="Contact"
149      description="How to get in contact with me."
150      gridArea="1 / 1 / 4 / 7"
151    >
152      <div className={styles.background} />
153      <div className={styles.grid}>
154        <div className={styles.gridLeft}>
155          <div className={styles.contactWrapper}>
156            <h1 className={styles.heading}>Contact Me</h1>
157            <h2 className={styles.shoutOut}>Shoot me a message.</h2>
158            <h2 className={styles.shoutOut}>
159              If it&#39;s interesting, I&#39;ll respond.
160            </h2>
161            <dl className={styles.contactList}>
162              <dt className={styles.contactKey}>Email</dt>
163              <dd className={styles.contactValue}>
164                <a
165                  className={styles.contactLink}
166                  href="mailto:[email protected]"
167                >
168                  isaac@bythewood.me
169                </a>
170              </dd>
171              <dt className={styles.contactKey}>LinkedIn</dt>
172              <dd className={styles.contactValue}>
173                <a
174                  className={styles.contactLink}
175                  href="https://www.linkedin.com/in/ibythewood/"
176                  rel="noopener noreferrer"
177                  target="_blank"
178                >
179                  Isaac Bythewood
180                </a>
181              </dd>
182              <dt className={styles.contactKey}>GitHub</dt>
183              <dd className={styles.contactValue}>
184                <a
185                  className={styles.contactLink}
186                  href="https://github.com/overshard"
187                  rel="noopener noreferrer"
188                  target="_blank"
189                >
190                  /overshard
191                </a>
192              </dd>
193              <dt className={styles.contactKey}>Discord</dt>
194              <dd className={styles.contactValue}>
195                <a
196                  className={styles.contactLink}
197                  href="https://discordapp.com/"
198                  rel="noopener noreferrer"
199                  target="_blank"
200                >
201                  Overshard#4907
202                </a>
203              </dd>
204            </dl>
205          </div>
206        </div>
207        <div className={styles.gridRight} ref={chatContainerRef}>
208          <div className={styles.chat}>
209            {chatHistory.map((msg, i) => (
210              <div
211                key={i}
212                className={`${styles.chatLine} ${
213                  msg.type === "user" ? styles.chatLineUser : ""
214                }`}
215              >
216                {msg.type === "isaac" && (
217                  <span className={styles.chatAvatar}>
218                    <Image
219                      src="/static/images/avatar.webp"
220                      alt="Isaac"
221                      width={40}
222                      height={40}
223                    />
224                  </span>
225                )}
226                <div
227                  className={`${styles.chatBubble} ${
228                    msg.type === "user" ? styles.chatBubbleUser : ""
229                  }`}
230                >
231                  {msg.text}
232                  {msg.type === "isaac" && <span>Isaac</span>}
233                </div>
234              </div>
235            ))}
236            {typing && (
237              <div className={styles.chatLine}>
238                <span className={styles.chatAvatar}>
239                  <Image
240                    src="/static/images/avatar.webp"
241                    alt="Isaac"
242                    width={40}
243                    height={40}
244                  />
245                </span>
246                <div className={styles.chatBubble}>
247                  <span className={styles.typingDots}>
248                    <span />
249                    <span />
250                    <span />
251                  </span>
252                </div>
253              </div>
254            )}
255            {showOptions && (
256              <div className={styles.chatOptions}>
257                {node.options.map((opt, i) => (
258                  <button
259                    key={i}
260                    className={styles.chatOption}
261                    onClick={() => handleOption(opt)}
262                  >
263                    {opt.label}
264                  </button>
265                ))}
266              </div>
267            )}
268
269          </div>
270        </div>
271      </div>
272    </Page>
273  );
274};
275
276export default Contact;