diff --git a/assets/js/main.js b/assets/js/main.js index 29e82726..6aeda464 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -1,4 +1,4 @@ -import "./src/dark-mode.js"; +import "./src/theme.js"; import "./src/emoji.js"; import "./src/hits.js"; import "./src/clipboard.js"; diff --git a/assets/js/src/anchor.js b/assets/js/src/anchor.js index 1a1827fc..b7554986 100644 --- a/assets/js/src/anchor.js +++ b/assets/js/src/anchor.js @@ -1,7 +1,7 @@ -// Heavily inspired by AnchorJS: -// https://github.com/bryanbraun/anchorjs +import { h, render } from "preact"; -import isTouchDevice from "is-touch-device"; +// react components: +import Anchor from "./components/Anchor.js"; // loop through each h2, h3, h4 in this page's content area // prettier-ignore @@ -9,24 +9,15 @@ document.querySelectorAll([ "div#content h2", "div#content h3", "div#content h4", -]).forEach((h) => { +]).forEach((heading) => { // don't add to elements without a pre-existing ID (e.g. `

`) - if (!h.hasAttribute("id")) { + if (!heading.hasAttribute("id")) { return; } - // build the anchor link (the "#" icon is added via CSS) - const anchor = document.createElement("a"); - anchor.className = "anchorjs-link"; - anchor.href = `#${h.getAttribute("id")}`; - anchor.ariaLabel = "Anchor"; + // TODO: little hacky hack to make the anchor appear AFTER the existing h tag + const linkTarget = document.createElement("a"); + heading.appendChild(linkTarget); - // if this is a touchscreen, always show the "#" icon instead waiting for hover - // NOTE: this is notoriously unreliable; see https://github.com/Modernizr/Modernizr/pull/2432 - if (isTouchDevice()) { - anchor.style.opacity = "1"; - } - - // add anchor link to the right of the heading - h.appendChild(anchor); + render(, heading, linkTarget); }); diff --git a/assets/js/src/assets/bulb-off.svg b/assets/js/src/assets/bulb-off.svg new file mode 100644 index 00000000..929ef440 --- /dev/null +++ b/assets/js/src/assets/bulb-off.svg @@ -0,0 +1 @@ + diff --git a/assets/js/src/assets/bulb-on.svg b/assets/js/src/assets/bulb-on.svg new file mode 100644 index 00000000..551fba75 --- /dev/null +++ b/assets/js/src/assets/bulb-on.svg @@ -0,0 +1 @@ + diff --git a/assets/js/src/clipboard.js b/assets/js/src/clipboard.js index 0610ee23..61c5e66e 100644 --- a/assets/js/src/clipboard.js +++ b/assets/js/src/clipboard.js @@ -1,43 +1,7 @@ import { h, render } from "preact"; -import { useState } from "preact/hooks"; -import copy from "clipboard-copy"; -import trimNewlines from "trim-newlines"; -// shared react components: -import { CopyIcon, CheckIcon } from "@primer/octicons-react"; - -const CopyButton = (props) => { - const [copied, setCopied] = useState(false); - - const handleCopy = (e) => { - // stop browser from navigating away from page (this shouldn't happen anyways) - e.preventDefault(); - // prevent unintentional double-clicks by unfocusing button - e.target.blur(); - - // trim any surrounding whitespace from target block's content and send it to the clipboard - copy(trimNewlines(props.content)); - - // indicate success... - setCopied(true); - // ...but reset everything after 2 seconds - setTimeout(() => { - setCopied(false); - }, 2000); - }; - - return ( - - ); -}; +// react components: +import CopyButton from "./components/CopyButton.js"; // loop through each code fence on page (if any) document.querySelectorAll("div.highlight").forEach((highlightDiv) => { diff --git a/assets/js/src/components/Anchor.js b/assets/js/src/components/Anchor.js new file mode 100644 index 00000000..e7a28da0 --- /dev/null +++ b/assets/js/src/components/Anchor.js @@ -0,0 +1,21 @@ +import { h } from "preact"; +import isTouchDevice from "is-touch-device"; + +const Anchor = (props) => { + return ( + // eslint-disable-next-line jsx-a11y/anchor-has-content + + ); +}; + +export default Anchor; diff --git a/assets/js/src/components/ContactForm.js b/assets/js/src/components/ContactForm.js new file mode 100644 index 00000000..d48c9cb4 --- /dev/null +++ b/assets/js/src/components/ContactForm.js @@ -0,0 +1,154 @@ +import { h, Fragment } from "preact"; +import { useState } from "preact/hooks"; +import fetch from "unfetch"; +import { isDark } from "../utils/theme.js"; + +// react components: +import HCaptcha from "@hcaptcha/react-hcaptcha"; +import { CheckIcon, XIcon } from "@primer/octicons-react"; +import SendEmoji from "twemoji-emojis/vendor/svg/1f4e4.svg"; + +const ContactForm = () => { + // status/feedback: + const [status, setStatus] = useState({ success: false, message: "" }); + // keep track of fetch: + const [sending, setSending] = useState(false); + + const onSubmit = (e) => { + // immediately prevent browser from actually navigating to a new page + e.preventDefault(); + + // begin the process + setSending(true); + + // extract data from form fields + const formData = { + name: e.target.elements.name?.value, + email: e.target.elements.email?.value, + message: e.target.elements.message?.value, + "h-captcha-response": e.target.elements["h-captcha-response"]?.value, + }; + + // some client-side validation to save requests (these are also checked on the server to be safe) + // TODO: change border color of the specific empty/missing field(s) to red + if (!(formData.name && formData.email && formData.message && formData["h-captcha-response"])) { + setSending(false); + setStatus({ success: false, message: "Please make sure that all fields are filled in." }); + + // remove focus from the submit button + document.activeElement.blur(); + + return; + } + + // if we've gotten here then all data is (or should be) valid and ready to post to API + fetch("/api/contact/", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(formData), + }) + .then((response) => response.json()) + .then((data) => { + setSending(false); + + if (data.success === true) { + // handle successful submission + // disable submissions, hide the send button, and let user know we were successful + setStatus({ success: true, message: "Thanks! You should hear from me soon." }); + } else { + // pass on any error sent by the server + throw new Error(data.message); + } + }) + .catch((error) => { + const message = error instanceof Error ? error.message : "UNKNOWN_EXCEPTION"; + + setSending(false); + + // give user feedback based on the error message returned + if (message === "USER_INVALID_CAPTCHA") { + setStatus({ + success: false, + message: "Did you complete the CAPTCHA? (If you're human, that is...)", + }); + } else if (message === "USER_MISSING_DATA") { + setStatus({ + success: false, + message: "Please make sure that all fields are filled in.", + }); + } else { + // something else went wrong, and it's probably my fault... + setStatus({ success: false, message: "Internal server error. Try again later?" }); + } + + // remove focus from the submit button + document.activeElement.blur(); + }); + }; + + return ( +
+ + +