1
mirror of https://github.com/jakejarvis/jarv.is.git synced 2025-09-13 04:05:35 -04:00

major refactoring of preact components 🧩 (#689)

This commit is contained in:
2021-12-15 08:35:18 -05:00
committed by GitHub
parent cda7d538a6
commit d119a98a0d
30 changed files with 1005 additions and 1053 deletions

View File

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

View File

@@ -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. `<h2 id="...">`)
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(<Anchor id={heading.getAttribute("id")} title={heading.textContent.trim()} />, heading, linkTarget);
});

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 35"><g fill="none"><path d="M22 11.06c0 6.44-5 7.44-5 13.44 0 3.1-3.12 3.36-5.5 3.36-2.05 0-6.59-.78-6.59-3.36 0-6-4.91-7-4.91-13.44C0 5.03 5.29.14 11.08.14 16.88.14 22 5.03 22 11.06z" fill="#CCCBCB"/><path d="M15.17 32.5c0 .83-2.24 2.5-4.17 2.5-1.93 0-4.17-1.67-4.17-2.5 0-.83 2.24-.5 4.17-.5 1.93 0 4.17-.33 4.17.5z" fill="#CCD6DD"/><path d="M15.7 10.3a1 1 0 0 0-1.4 0L11 13.58l-3.3-3.3a1 1 0 1 0-1.4 1.42l3.7 3.7V26a1 1 0 1 0 2 0V15.41l3.7-3.7a1 1 0 0 0 0-1.42z" fill="#7D7A72"/><path d="M17 31a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-6h12v6z" fill="#99AAB5"/><path d="M5 32a1 1 0 0 1-.16-1.99l12-2a1 1 0 1 1 .33 1.97l-12 2A.93.93 0 0 1 5 32zm0-4a1 1 0 0 1-.16-1.99l12-2a1 1 0 1 1 .33 1.97l-12 2A.93.93 0 0 1 5 28z" fill="#CCD6DD"/></g></svg>

After

Width:  |  Height:  |  Size: 793 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 35"><g fill="none"><path d="M22 11.06c0 6.44-5 7.44-5 13.44 0 3.1-3.12 3.36-5.5 3.36-2.05 0-6.59-.78-6.59-3.36 0-6-4.91-7-4.91-13.44C0 5.03 5.29.14 11.08.14 16.88.14 22 5.03 22 11.06z" fill="#FFD983"/><path d="M15.17 32.5c0 .83-2.24 2.5-4.17 2.5-1.93 0-4.17-1.67-4.17-2.5 0-.83 2.24-.5 4.17-.5 1.93 0 4.17-.33 4.17.5z" fill="#B9C9D9"/><path d="M15.7 10.3a1 1 0 0 0-1.4 0L11 13.58l-3.3-3.3a1 1 0 1 0-1.4 1.42l3.7 3.7V26a1 1 0 1 0 2 0V15.41l3.7-3.7a1 1 0 0 0 0-1.42z" fill="#FFCC4D"/><path d="M17 31a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-6h12v6z" fill="#99AAB5"/><path d="M5 32a1 1 0 0 1-.16-1.99l12-2a1 1 0 1 1 .33 1.97l-12 2A.93.93 0 0 1 5 32zm0-4a1 1 0 0 1-.16-1.99l12-2a1 1 0 1 1 .33 1.97l-12 2A.93.93 0 0 1 5 28z" fill="#CCD6DD"/></g></svg>

After

Width:  |  Height:  |  Size: 793 B

View File

@@ -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 (
<button
class="copy-button"
title="Copy to clipboard"
aria-label="Copy to clipboard"
onClick={handleCopy}
disabled={copied}
>
{copied ? <CheckIcon size={16} /> : <CopyIcon size={16} />}
</button>
);
};
// react components:
import CopyButton from "./components/CopyButton.js";
// loop through each code fence on page (if any)
document.querySelectorAll("div.highlight").forEach((highlightDiv) => {

View File

@@ -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
<a
class="anchorjs-link"
href={`#${props.id}`}
title={`Jump to "${props.title}"`}
aria-label={`Jump to "${props.title}"`}
style={{
// 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
opacity: isTouchDevice() ? 1 : null,
}}
/>
);
};
export default Anchor;

View File

@@ -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 (
<form onSubmit={onSubmit} id="contact-form" action="/api/contact/" method="POST">
<input type="text" name="name" placeholder="Name" disabled={status.success} />
<input type="email" name="email" placeholder="Email" disabled={status.success} />
<textarea name="message" placeholder="Write something..." disabled={status.success} />
<div id="contact-form-md-info">
Basic{" "}
<a
href="https://commonmark.org/help/"
title="Markdown reference sheet"
target="_blank"
rel="noopener noreferrer"
>
Markdown syntax
</a>{" "}
is allowed here, e.g.: <strong>**bold**</strong>, <em>_italics_</em>, [
<a href="https://jarv.is" target="_blank" rel="noopener noreferrer">
links
</a>
](https://jarv.is), and <code>`code`</code>.
</div>
<div id="contact-form-captcha">
<HCaptcha
sitekey={process.env.HCAPTCHA_SITE_KEY}
theme={isDark() ? "dark" : "light"}
size="normal"
reCaptchaCompat={false}
onVerify={() => true} // this is allegedly optional but a function undefined error is thrown without it
/>
</div>
<div id="contact-form-action-row">
<button
id="contact-form-btn-submit"
title="Send Message"
aria-label="Send Message"
disabled={sending}
style={{ display: status.success ? "none" : null }}
>
{sending ? (
<span>Sending...</span>
) : (
<>
<SendEmoji class="emoji" /> <span>Send</span>
</>
)}
</button>
<span
class="contact-form-result"
id={status.success ? "contact-form-result-success" : "contact-form-result-error"}
style={{ display: !status.message || sending ? "none" : null }}
>
{status.success ? <CheckIcon size={16} /> : <XIcon size={16} />} {status.message}
</span>
</div>
</form>
);
};
export default ContactForm;

View File

@@ -0,0 +1,42 @@
import { h } from "preact";
import { useState } from "preact/hooks";
import copy from "clipboard-copy";
import trimNewlines from "trim-newlines";
// 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 (
<button
class="copy-button"
title="Copy to clipboard"
aria-label="Copy to clipboard"
onClick={handleCopy}
disabled={copied}
>
{copied ? <CheckIcon size={16} /> : <CopyIcon size={16} />}
</button>
);
};
export default CopyButton;

View File

@@ -0,0 +1,31 @@
import { h } from "preact";
import { useState, useEffect } from "preact/hooks";
import fetch from "unfetch";
// react components:
import Loading from "./Loading.js";
const Counter = (props) => {
const [hits, setHits] = useState();
// start fetching hits from API once slug is set
useEffect(() => {
fetch(`/api/hits/?slug=${encodeURIComponent(props.slug)}`)
.then((response) => response.json())
.then((data) => setHits(data.hits || 0));
}, [props.slug]);
// show spinning loading indicator if data isn't fetched yet
if (!hits) {
return <Loading boxes={3} width={20} />;
}
// we have data!
return (
<span title={`${hits.toLocaleString("en-US")} ${hits === 1 ? "view" : "views"}`}>
{hits.toLocaleString("en-US")}
</span>
);
};
export default Counter;

View File

@@ -0,0 +1,79 @@
import { h } from "preact";
import dayjs from "dayjs";
import dayjsRelativeTime from "dayjs/plugin/relativeTime.js";
import parseEmoji from "../utils/parseEmoji.js";
// react components:
import { StarIcon, RepoForkedIcon } from "@primer/octicons-react";
// dayjs plugins: https://day.js.org/docs/en/plugin/loading-into-nodejs
dayjs.extend(dayjsRelativeTime);
const RepositoryCard = (props) => (
<div class="github-card">
<a class="repo-name" href={props.url} target="_blank" rel="noopener noreferrer">
{props.name}
</a>
{props.description && (
<p
class="repo-description"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: parseEmoji(props.description) }}
/>
)}
<div class="repo-meta">
{props.language && (
<div class="repo-meta-item">
<span class="repo-language-color" style={{ "background-color": props.language.color }} />
<span>{props.language.name}</span>
</div>
)}
{props.stars > 0 && (
<div class="repo-meta-item">
<a
href={`${props.url}/stargazers`}
title={`${props.stars.toLocaleString("en-US")} ${props.stars === 1 ? "star" : "stars"}`}
target="_blank"
rel="noopener noreferrer"
>
<StarIcon size={16} />
<span>{props.stars.toLocaleString("en-US")}</span>
</a>
</div>
)}
{props.forks > 0 && (
<div class="repo-meta-item">
<a
href={`${props.url}/network/members`}
title={`${props.forks.toLocaleString("en-US")} ${props.forks === 1 ? "fork" : "forks"}`}
target="_blank"
rel="noopener noreferrer"
>
<RepoForkedIcon size={16} />
<span>{props.forks.toLocaleString("en-US")}</span>
</a>
</div>
)}
<div
class="repo-meta-item"
title={new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "numeric",
timeZoneName: "short",
}).format(new Date(props.updatedAt))}
>
<span>Updated {dayjs(props.updatedAt).fromNow()}</span>
</div>
</div>
</div>
);
export default RepositoryCard;

View File

@@ -0,0 +1,36 @@
import { h, Fragment } from "preact";
import { useState, useEffect } from "preact/hooks";
import fetch from "unfetch";
// react components:
import Loading from "./Loading.js";
import RepositoryCard from "./RepositoryCard.js";
const RepositoryGrid = () => {
const [repos, setRepos] = useState([]);
// start fetching repos from API immediately
useEffect(() => {
// API endpoint (sort by stars, limit to 12)
fetch("/api/projects/?top&limit=12")
.then((response) => response.json())
.then((data) => setRepos(data || []));
}, []);
// show spinning loading indicator if data isn't fetched yet
if (repos.length === 0) {
return <Loading boxes={3} width={40} style={{ margin: "0.7em auto" }} />;
}
// we have data!
return (
<>
{repos.map((repo) => (
// eslint-disable-next-line react/jsx-key
<RepositoryCard {...repo} />
))}
</>
);
};
export default RepositoryGrid;

View File

@@ -0,0 +1,36 @@
import { h } from "preact";
import { useState, useEffect } from "preact/hooks";
import { isDark, setDarkClass, setDarkPref } from "../utils/theme.js";
// react components:
import BulbOn from "../assets/bulb-on.svg";
import BulbOff from "../assets/bulb-off.svg";
const ThemeToggle = () => {
// sync button up with theme state after initialization
const [dark, setDark] = useState(isDark());
useEffect(() => {
setDarkClass(dark);
}, [dark]);
const handleToggle = () => {
// only update the local storage preference if the user explicitly presses the lightbulb
setDarkPref(!dark);
// set theme to the opposite of current theme
setDark(!dark);
};
return (
<button
onClick={handleToggle}
title={dark ? "Toggle Light Mode" : "Toggle Dark Mode"}
aria-label={dark ? "Toggle Light Mode" : "Toggle Dark Mode"}
>
{dark ? <BulbOff /> : <BulbOn />}
</button>
);
};
export default ThemeToggle;

View File

@@ -1,158 +1,9 @@
import { h, Fragment, render } from "preact";
import { useState } from "preact/hooks";
import fetch from "unfetch";
import { h, render } from "preact";
// shared 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 CONTACT_ENDPOINT = "/api/contact/";
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(CONTACT_ENDPOINT, {
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 (
<form onSubmit={onSubmit} id="contact-form" action={CONTACT_ENDPOINT} method="POST">
<input type="text" name="name" placeholder="Name" disabled={status.success} />
<input type="email" name="email" placeholder="Email" disabled={status.success} />
<textarea name="message" placeholder="Write something..." disabled={status.success} />
<div id="contact-form-md-info">
Basic{" "}
<a
href="https://commonmark.org/help/"
title="Markdown reference sheet"
target="_blank"
rel="noopener noreferrer"
>
Markdown syntax
</a>{" "}
is allowed here, e.g.: <strong>**bold**</strong>, <em>_italics_</em>, [
<a href="https://jarv.is" target="_blank" rel="noopener noreferrer">
links
</a>
](https://jarv.is), and <code>`code`</code>.
</div>
<div id="contact-form-captcha">
<HCaptcha
sitekey={process.env.HCAPTCHA_SITE_KEY}
theme={document.body.classList.contains("dark") ? "dark" : "light"}
size="normal"
reCaptchaCompat={false}
onVerify={() => true} // this is allegedly optional but a function undefined error is thrown without it
/>
</div>
<div id="contact-form-action-row">
<button
id="contact-form-btn-submit"
title="Send Message"
aria-label="Send Message"
disabled={sending}
style={{ display: status.success ? "none" : null }}
>
{sending ? (
<span>Sending...</span>
) : (
<>
<SendEmoji class="emoji" /> <span>Send</span>
</>
)}
</button>
<span
class="contact-form-result"
id={status.success ? "contact-form-result-success" : "contact-form-result-error"}
style={{ display: !status.message || sending ? "none" : null }}
>
{status.success ? <CheckIcon size={16} /> : <XIcon size={16} />} {status.message}
</span>
</div>
</form>
);
};
// react components:
import ContactForm from "./components/ContactForm.js";
// don't continue if there isn't a contact form on this page
if (typeof window !== "undefined" && document.querySelector("div#contact-form-wrapper")) {
render(<ContactForm />, document.querySelector("div#contact-form-wrapper"));
if (typeof window !== "undefined" && document.querySelector(".layout-contact #contact-form-wrapper")) {
render(<ContactForm />, document.querySelector(".layout-contact #contact-form-wrapper"));
}

View File

@@ -1,91 +0,0 @@
// use a specified element(s) to trigger swap when clicked
const toggle = document.querySelector(".dark-mode-toggle");
// check for existing preference in local storage
const storageKey = "theme";
const pref = localStorage.getItem(storageKey);
// prepare a temporary stylesheet for fancy transitions
const fadeStyle = document.createElement("style");
// change CSS via these <body> classes:
const dark = "dark";
const light = "light";
// which class is <body> set to initially?
const defaultTheme = light;
// keep track of current state no matter how we got there
let active = defaultTheme === dark;
// receives a class name and switches <body> to it
const activateTheme = (theme, opts) => {
if (opts?.fade) {
document.head.append(fadeStyle);
// apply a short transition to all properties of all elements
// TODO: this causes some extreme performance hiccups (especially in chromium)
fadeStyle.sheet.insertRule(`
*, ::before, ::after {
transition: all 0.15s linear !important;
}
`);
// remove the stylesheet when body is done transitioning
document.body.addEventListener("transitionend", () => {
fadeStyle.remove();
});
}
document.body.classList.remove(dark, light);
document.body.classList.add(theme);
active = theme === dark;
if (opts?.save) {
localStorage.setItem(storageKey, theme);
}
};
// user has never clicked the button, so go by their OS preference until/if they do so
if (!pref) {
// returns media query selector syntax
// https://drafts.csswg.org/mediaqueries-5/#prefers-color-scheme
const prefers = (colorScheme) => `(prefers-color-scheme: ${colorScheme})`;
// check for OS dark/light mode preference and switch accordingly
// default to `defaultTheme` set above if unsupported
if (window.matchMedia(prefers("dark")).matches) {
activateTheme(dark);
} else if (window.matchMedia(prefers("light")).matches) {
activateTheme(light);
} else {
activateTheme(defaultTheme);
}
// real-time switching (if supported by OS/browser)
window.matchMedia(prefers("dark")).addEventListener("change", (e) => e.matches && activateTheme(dark));
window.matchMedia(prefers("light")).addEventListener("change", (e) => e.matches && activateTheme(light));
} else if (pref === dark || pref === light) {
// if user already explicitly toggled in the past, restore their preference
activateTheme(pref);
} else {
// fallback to default theme (this shouldn't happen)
activateTheme(defaultTheme);
}
// don't freak out if page happens not to have a toggle
if (toggle) {
// make toggle visible now that we know JS is enabled
toggle.style.display = "block";
// handle toggle click
toggle.addEventListener("click", () => {
// switch to the opposite theme & save preference in local storage
// TODO: enable fade.
if (active) {
activateTheme(light, { save: true });
} else {
activateTheme(dark, { save: true });
}
});
}

View File

@@ -1,11 +1,4 @@
import * as imagemoji from "imagemoji";
const parseEmoji = (what) =>
// we're hosting twemojis locally instead of from Twitter's CDN
imagemoji.parse(what, (icon) => `/assets/emoji/${icon}.svg`);
import parseEmoji from "./utils/parseEmoji.js";
// apply to the entire body automatically on load...
parseEmoji(document.body);
// ...but this can still be reused elsewhere so the URL above doesn't need to be changed in multiple places
export default parseEmoji;

View File

@@ -1,39 +1,11 @@
import { h, render } from "preact";
import { useState, useEffect } from "preact/hooks";
import fetch from "unfetch";
import canonicalUrl from "get-canonical-url";
// shared react components:
import Loading from "./components/loading.js";
// API endpoint
const HITS_ENDPOINT = "/api/hits/";
const Counter = (props) => {
const [hits, setHits] = useState();
// start fetching hits from API once slug is set
useEffect(() => {
fetch(`${HITS_ENDPOINT}?slug=${encodeURIComponent(props.slug)}`)
.then((response) => response.json())
.then((data) => setHits(data.hits || 0));
}, [props.slug]);
// show spinning loading indicator if data isn't fetched yet
if (!hits) {
return <Loading boxes={3} width={20} />;
}
// we have data!
return (
<span title={`${hits.toLocaleString("en-US")} ${hits === 1 ? "view" : "views"}`}>
{hits.toLocaleString("en-US")}
</span>
);
};
// react components:
import Counter from "./components/Counter.js";
// page must have a div#meta-hits-counter element to continue
if (typeof window !== "undefined" && document.querySelector("div#meta-hits-counter")) {
if (typeof window !== "undefined" && document.querySelector(".layout-single #meta-hits-counter")) {
// use <link rel="canonical"> to deduce a consistent identifier for this page
const canonical = canonicalUrl({
normalize: true,
@@ -47,5 +19,5 @@ if (typeof window !== "undefined" && document.querySelector("div#meta-hits-count
// get path and strip beginning and ending forward slash
const slug = new URL(canonical).pathname.replace(/^\/|\/$/g, "") || "/";
render(<Counter slug={slug} />, document.querySelector("div#meta-hits-counter"));
render(<Counter slug={slug} />, document.querySelector(".layout-single #meta-hits-counter"));
}

View File

@@ -1,114 +1,9 @@
import { h, Fragment, render } from "preact";
import { useState, useEffect } from "preact/hooks";
import fetch from "unfetch";
import dayjs from "dayjs";
import dayjsRelativeTime from "dayjs/plugin/relativeTime.js";
import parseEmoji from "./emoji.js";
import { h, render } from "preact";
// shared react components:
import { StarIcon, RepoForkedIcon } from "@primer/octicons-react";
import Loading from "./components/loading.js";
// API endpoint (sort by stars, limit to 12)
const PROJECTS_ENDPOINT = "/api/projects/?top&limit=12";
const RepositoryGrid = () => {
const [repos, setRepos] = useState([]);
// start fetching repos from API immediately
useEffect(() => {
fetch(PROJECTS_ENDPOINT)
.then((response) => response.json())
.then((data) => setRepos(data || []));
}, []);
// show spinning loading indicator if data isn't fetched yet
if (repos.length === 0) {
return <Loading boxes={3} width={40} style={{ margin: "0.7em auto" }} />;
}
// we have data!
return (
<>
{repos.map((repo) => (
// eslint-disable-next-line react/jsx-key
<RepositoryCard {...repo} />
))}
</>
);
};
const RepositoryCard = (repo) => (
<div class="github-card">
<a class="repo-name" href={repo.url} target="_blank" rel="noopener noreferrer">
{repo.name}
</a>
{repo.description && (
<p
class="repo-description"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: parseEmoji(repo.description) }}
/>
)}
<div class="repo-meta">
{repo.language && (
<div class="repo-meta-item">
<span class="repo-language-color" style={{ "background-color": repo.language.color }} />
<span>{repo.language.name}</span>
</div>
)}
{repo.stars > 0 && (
<div class="repo-meta-item">
<a
href={`${repo.url}/stargazers`}
title={`${repo.stars.toLocaleString("en-US")} ${repo.stars === 1 ? "star" : "stars"}`}
target="_blank"
rel="noopener noreferrer"
>
<StarIcon size={16} />
<span>{repo.stars.toLocaleString("en-US")}</span>
</a>
</div>
)}
{repo.forks > 0 && (
<div class="repo-meta-item">
<a
href={`${repo.url}/network/members`}
title={`${repo.forks.toLocaleString("en-US")} ${repo.forks === 1 ? "fork" : "forks"}`}
target="_blank"
rel="noopener noreferrer"
>
<RepoForkedIcon size={16} />
<span>{repo.forks.toLocaleString("en-US")}</span>
</a>
</div>
)}
<div
class="repo-meta-item"
title={new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "numeric",
timeZoneName: "short",
}).format(new Date(repo.updatedAt))}
>
<span>Updated {dayjs(repo.updatedAt).fromNow()}</span>
</div>
</div>
</div>
);
// react components:
import RepositoryGrid from "./components/RepositoryGrid.js";
// detect if these cards are wanted on this page (only /projects)
if (typeof window !== "undefined" && document.querySelector("div#github-cards")) {
// dayjs plugins: https://day.js.org/docs/en/plugin/loading-into-nodejs
dayjs.extend(dayjsRelativeTime);
render(<RepositoryGrid />, document.querySelector("div#github-cards"));
if (typeof window !== "undefined" && document.querySelector(".layout-projects #github-cards")) {
render(<RepositoryGrid />, document.querySelector(".layout-projects #github-cards"));
}

29
assets/js/src/theme.js Normal file
View File

@@ -0,0 +1,29 @@
import { h, render } from "preact";
import { getDarkPref, setDarkClass } from "./utils/theme.js";
// react components:
import ThemeToggle from "./components/ThemeToggle.js";
// check for existing preference in local storage
const pref = getDarkPref();
// do initialization before *any* react-related stuff to avoid white flashes as much as possible
if (pref) {
// restore user's preference if they've explicitly toggled it in the past
setDarkClass(pref === "true");
} else {
// check for OS dark mode preference and switch accordingly
// https://drafts.csswg.org/mediaqueries-5/#prefers-color-scheme
try {
setDarkClass(window.matchMedia("(prefers-color-scheme: dark)").matches);
} catch (e) {}
// TODO: fix real-time switching (works but bulb icon isn't updated)
// window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (e) => e.matches && setDark(true));
// window.matchMedia("(prefers-color-scheme: light)").addEventListener("change", (e) => e.matches && setDark(false));
}
// finally render the nifty lightbulb in the header
if (typeof window !== "undefined" && document.querySelector(".theme-toggle")) {
render(<ThemeToggle />, document.querySelector(".theme-toggle"));
}

View File

@@ -0,0 +1,8 @@
import fetch from "unfetch";
const getData = (url) =>
fetch(url)
.then((response) => response.json())
.then((data) => data || []);
export default getData;

View File

@@ -0,0 +1,8 @@
import * as imagemoji from "imagemoji";
const parseEmoji = (what) =>
// we're hosting twemojis locally instead of from Twitter's CDN
imagemoji.parse(what, (icon) => `/assets/emoji/${icon}.svg`);
// reuse this so the URL above doesn't need to be changed in multiple places
export default parseEmoji;

View File

@@ -0,0 +1,19 @@
// store preference in local storage
const storageKey = "dark_mode";
export const getDarkPref = () => localStorage.getItem(storageKey);
export const setDarkPref = (pref) => localStorage.setItem(storageKey, pref);
// use the body class as a hint to what the theme was set to outside of the button component
// there's probably (definitely) a cleaner way to do this..?
export const isDark = () => document.body.classList?.contains("dark");
// sets appropriate `<body class="...">`
export const setDarkClass = (dark) => {
if (dark) {
document.body.classList.add("dark");
document.body.classList.remove("light");
} else {
document.body.classList.add("light");
document.body.classList.remove("dark");
}
};

View File

@@ -60,7 +60,3 @@ $themes: (
error: #ff5151,
),
);
// Icons (modified twemojis)
$icon-bulb-on: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 35"><g fill="none"><path d="M22 11.06c0 6.44-5 7.44-5 13.44 0 3.1-3.12 3.36-5.5 3.36-2.05 0-6.59-.78-6.59-3.36 0-6-4.91-7-4.91-13.44C0 5.03 5.29.14 11.08.14 16.88.14 22 5.03 22 11.06z" fill="#FFD983"/><path d="M15.17 32.5c0 .83-2.24 2.5-4.17 2.5-1.93 0-4.17-1.67-4.17-2.5 0-.83 2.24-.5 4.17-.5 1.93 0 4.17-.33 4.17.5z" fill="#B9C9D9"/><path d="M15.7 10.3a1 1 0 00-1.4 0L11 13.58l-3.3-3.3a1 1 0 10-1.4 1.42l3.7 3.7V26a1 1 0 102 0V15.41l3.7-3.7a1 1 0 000-1.42z" fill="#FFCC4D"/><path d="M17 31a2 2 0 01-2 2H7a2 2 0 01-2-2v-6h12v6z" fill="#99AAB5"/><path d="M5 32a1 1 0 01-.16-1.99l12-2a1 1 0 11.33 1.97l-12 2A.93.93 0 015 32zm0-4a1 1 0 01-.16-1.99l12-2a1 1 0 11.33 1.97l-12 2A.93.93 0 015 28z" fill="#CCD6DD"/></g></svg>';
$icon-bulb-off: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 35"><g fill-rule="nonzero" fill="none"><path d="M22 11.06c0 6.44-5 7.44-5 13.44 0 3.1-3.12 3.36-5.5 3.36-2.05 0-6.59-.78-6.59-3.36 0-6-4.91-7-4.91-13.44C0 5.03 5.29.14 11.08.14 16.88.14 22 5.03 22 11.06z" fill="#CCCBCB"/><path d="M15.17 32.5c0 .83-2.24 2.5-4.17 2.5-1.93 0-4.17-1.67-4.17-2.5 0-.83 2.24-.5 4.17-.5 1.93 0 4.17-.33 4.17.5z" fill="#CCD6DD"/><path d="M15.7 10.3a1 1 0 00-1.4 0L11 13.58l-3.3-3.3a1 1 0 10-1.4 1.42l3.7 3.7V26a1 1 0 102 0V15.41l3.7-3.7a1 1 0 000-1.42z" fill="#7D7A72"/><path d="M17 31a2 2 0 01-2 2H7a2 2 0 01-2-2v-6h12v6z" fill="#99AAB5"/><path d="M5 32a1 1 0 01-.16-1.99l12-2a1 1 0 11.33 1.97l-12 2A.93.93 0 015 32zm0-4a1 1 0 01-.16-1.99l12-2a1 1 0 11.33 1.97l-12 2A.93.93 0 015 28z" fill="#CCD6DD"/></g></svg>';

View File

@@ -32,7 +32,7 @@ div#content {
letter-spacing: 0.001em;
line-height: 1.5;
&:hover > a.anchorjs-link {
&:hover > .anchorjs-link {
opacity: 1; // '#' link appears on hover over entire sub-heading line
}
}
@@ -50,13 +50,14 @@ div#content {
}
// AnchorJS styles
a.anchorjs-link {
margin-left: 0.25em;
padding: 0 0.5em 0 0.25em;
.anchorjs-link {
margin: 0 0.25em;
padding: 0 0.25em;
background: none;
opacity: 0;
font-weight: 300;
line-height: 1;
opacity: 0; // overridden by JS on mobile devices
user-select: none;
&::before {
content: "\0023"; // pound sign

View File

@@ -24,15 +24,6 @@ body {
background-color: "background-outer",
)
);
// set themed lightbulb icons manually
&.light button.dark-mode-toggle {
background-image: url("data:image/svg+xml;charset=utf-8,#{themes.$icon-bulb-on}");
}
&.dark button.dark-mode-toggle {
background-image: url("data:image/svg+xml;charset=utf-8,#{themes.$icon-bulb-off}");
}
}
code,
@@ -141,8 +132,7 @@ main {
// make SVG twemojis relative to surrounding text
// https://github.com/twitter/twemoji#inline-styles
img.emoji,
svg.emoji {
.emoji {
height: 1.2em;
width: 1.2em;
margin: 0 0.05em;
@@ -160,16 +150,12 @@ a .emoji {
}
// pulsating loading spinner
.loading {
display: inline-block;
> div {
@include themes.themed(
(
background-color: "medium-light",
)
);
}
div.loading > div {
@include themes.themed(
(
background-color: "medium-light",
)
);
}
// Responsive

View File

@@ -69,7 +69,7 @@ header {
ul {
list-style: none;
display: flex;
align-items: baseline;
align-items: center;
margin: 0;
padding: 0;
@@ -78,6 +78,7 @@ header {
a {
display: inline-flex;
align-items: center;
@include themes.themed(
(
@@ -94,11 +95,8 @@ header {
}
span {
align-self: center;
&.header-menu-icon {
font-size: 1.3em;
vertical-align: -0.085em;
user-select: none;
}
@@ -112,22 +110,16 @@ header {
}
// Dark mode toggle
&#header-lightbulb {
align-self: center;
&.theme-toggle button {
border: 0;
padding: 0;
background: none;
margin: 0.3em -0.3em 0 1.4em;
cursor: pointer;
button {
// native button reset
border: 0;
padding: 0;
svg {
width: 1.56em; // 24.33px, don't ask
height: 1.56em;
margin: -0.075em -0.3em 0 1.4em; // weirdness w/ svg ratio
cursor: pointer;
// prepare for lightbulb symbol depending on active theme (set in components/_global)
background-color: transparent;
background-repeat: no-repeat;
background-size: 100% 100%;
}
}
@@ -183,11 +175,14 @@ header {
}
// Dark mode toggle
&#header-lightbulb button.dark-mode-toggle {
width: 1.08em; // ~27px, don't ask
height: 1.08em;
&.theme-toggle button {
margin-left: 1em;
margin-right: -0.2em; // weirdness w/ svg ratio
svg {
width: 1.08em; // ~27px, don't ask
height: 1.08em;
}
}
}
}

View File

@@ -16,9 +16,7 @@
</a>
</li>
{{- end }}
<li id="header-lightbulb">
<button class="dark-mode-toggle" title="Toggle Dark Mode" aria-label="Toggle Dark Mode" style="display: none;"></button>
</li>
<li class="theme-toggle"></li>
</ul>
</nav>
</header>

View File

@@ -30,7 +30,7 @@
"@fontsource/roboto-mono": "4.5.0",
"@hcaptcha/react-hcaptcha": "^0.3.9",
"@octokit/graphql": "^4.8.0",
"@primer/octicons-react": "^16.1.1",
"@primer/octicons-react": "^16.2.0",
"@sentry/node": "^6.16.1",
"clipboard-copy": "^4.0.1",
"dayjs": "^1.10.7",
@@ -52,10 +52,10 @@
"unfetch": "^4.2.0"
},
"devDependencies": {
"@babel/core": "^7.16.0",
"@babel/eslint-parser": "^7.16.3",
"@babel/preset-env": "^7.16.4",
"@babel/preset-react": "^7.16.0",
"@babel/core": "^7.16.5",
"@babel/eslint-parser": "^7.16.5",
"@babel/preset-env": "^7.16.5",
"@babel/preset-react": "^7.16.5",
"@jakejarvis/eslint-config": "github:jakejarvis/eslint-config#main",
"@svgr/webpack": "^6.1.2",
"autoprefixer": "^10.4.0",

View File

@@ -1,75 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>403 Forbidden</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<style>
body {
background: #7a7a7a;
color: #f1f1f1;
font-family: Helvetica, Arial, sans-serif;
text-align: center;
margin-top: 2rem;
}
a {
color: inherit;
}
a:hover {
color: #c9c9c9;
}
h1, h2, svg {
margin: 1.25rem 0;
}
h1 {
font-size: 2.2rem;
}
h2 {
font-size: 1.2rem;
}
</style>
</head>
<body>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 229 232" width="229" height="232">
<!-- Generator: Sketch 49.3 (51167) - http://www.bohemiancoding.com/sketch -->
<g fill="none" fill-rule="evenodd">
<path fill="#161515" d="M36 144c2-19 51-45 84-41s63 25 71 41-3 74-3 74H49s-18-40-13-74"/>
<path fill="#161515" d="M63 102s-4-7 5-18 53-21 76-4c23 16 26 17 24 22-1 5-35 28-38 38-2 10-14 27-15 27 0 0-10-20-18-30-7-10-34-35-34-35"/>
<path fill="#FFB18F" d="M80 75c-1 7 6 30 13 40s18 16 24 16c6 1 16-6 23-19 6-14 12-38 12-38l-72 1"/>
<path fill="#DB826A" d="M151 77l-2-3-69 1c-1 6 3 20 8 31l21-7c4-2 2-6 6-7s3 6 6 7l21 8 9-30"/>
<path fill="#90CED6" d="M64 100s12-15 48-14c35 0 53 14 53 14s-31-12-56-11c-20 1-45 11-45 11"/>
<path fill="#353535" d="M59 102c-1 0 15-54 37-69 12-9 34-8 46 1 14 12 30 67 29 69 0 2-19-14-56-15-39-1-53 15-56 14"/>
<path fill="#353535" d="M59 102s2-4 4-3c14 6 45 35 50 50 6 21 3 32 3 32s-9-26-24-44c-11-14-33-35-33-35"/>
<path fill="#353535" d="M171 103s-2-5-4-4c-13 7-45 35-49 51-6 20-2 31-2 31s8-25 22-43c11-14 33-35 33-35"/>
<path fill="#DB826A" d="M109 104l5-2 6 3c-1 1-11 0-11-1"/>
<path fill="#7C4639" d="M106 116c-1-1 4-2 10-2h7l-7 1c-3 0-10 3-10 1"/>
<path fill="#DB826A" d="M105 113c1 1-2 3-1 4l4 2s-3 1-5-2c-1-1 1-5 2-4"/>
<path fill="#444444" d="M118 32c-14 0-25 43-28 58a112 112 0 0151 1c-2-15-5-59-23-59"/>
<path fill="#90CED6" d="M115 179c-1-12-5-29-20-44-8-9-16-15-22-19a186 186 0 0142 63"/>
<path fill="#353535" d="M45 130c-17 13-37 63-40 79-3 17 53 22 63 17s-6-73-12-81c-5-8-8-18-11-15"/>
<path fill="#474747" d="M138 138l18-20c-6 4-12 10-19 17a78 78 0 00-19 43c2-8 9-26 20-40"/>
<path fill="#232323" d="M50 159s-7 17-11 21c-3 4-11 1-15 4s-5 13-5 13 10-7 19-4c10 4 17-3 17-3l-5-31"/>
<path fill="#2B2B2B" d="M51 198s-15-4-20 1c-4 4-4 8-4 8s3-4 7-5c7-2 21 7 15-1l2-3"/>
<path fill="#353535" d="M180 130c16 13 36 63 39 79 3 17-53 22-63 17s6-73 12-81 8-18 12-15"/>
<path fill="#232323" d="M175 159s6 17 10 21 11 1 16 4c4 3 5 13 5 13s-10-7-20-4c-10 4-17-3-17-3l6-31"/>
<path fill="#2B2B2B" d="M174 198s14-4 20 1c4 4 4 8 4 8s-4-4-8-5c-6-2-21 7-15-1l-1-3"/>
<path fill="#2B2B2B" d="M199 179s-2-7-7-8l-11-7s3 12 5 13c6 2 13 2 13 2"/>
<path fill="#2B2B2B" d="M26 179s2-7 6-8l11-7s-2 12-5 13c-5 2-12 2-12 2"/>
<polygon fill="#AAAAAA" points="48.105165 228.092039 187.147495 228.092039 187.147495 147.475417 48.105165 147.475417"/>
<polygon fill="#8C8C8C" points="48.105165 231.972971 187.147495 231.972971 187.147495 228.004194 48.105165 228.004194"/>
<polygon fill="#CECECE" points="73.055301 228.303767 87.8447379 228.303767 87.8447379 226.927534 73.055301 226.927534"/>
<polygon fill="#CECECE" points="147.40567 228.303767 162.199612 228.303767 162.199612 226.927534 147.40567 226.927534"/>
<polygon fill="#CECECE" points="48.105165 149.540893 187.147495 149.540893 187.147495 147.666874 48.105165 147.666874"/>
<polyline fill="#474747" points="114.936932 176.946175 108.936466 176.946175 108.936466 182.674097 110.702369 182.674097 110.702369 178.531883 114.936932 178.531883 114.936932 176.946175"/>
<polyline fill="#474747" points="120.317981 176.946175 126.318447 176.946175 126.318447 182.674097 124.554796 182.674097 124.554796 178.531883 120.317981 178.531883 120.317981 176.946175"/>
<polyline fill="#474747" points="120.317981 193.251495 126.318447 193.251495 126.318447 187.525825 124.554796 187.525825 124.554796 191.668039 120.317981 191.668039 120.317981 193.251495"/>
<polyline fill="#474747" points="114.936932 193.251495 108.936466 193.251495 108.936466 187.525825 110.702369 187.525825 110.702369 191.668039 114.936932 191.668039 114.936932 193.251495"/>
</g>
</svg>
<h1>Fancy meeting you here...</h1>
<h2>Nice try, though! <a href="/">Shall we head back home?</a> 👮</h2>
</body>
</html>

947
yarn.lock

File diff suppressed because it is too large Load Diff