1
mirror of https://github.com/jakejarvis/jarv.is.git synced 2026-06-29 20:06:00 -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
+1 -1
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";
+9 -18
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);
});
+1
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

+1
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

+2 -38
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) => {
+21
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;
+154
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;
+42
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;
+31
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;
@@ -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;
@@ -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;
+36
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;
+5 -154
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"));
}
-91
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 });
}
});
}
+1 -8
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;
+4 -32
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"));
}
+5 -110
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
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"));
}
+8
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;
+8
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;
+19
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");
}
};