mirror of
https://github.com/jakejarvis/jarv.is.git
synced 2025-09-13 05:45:31 -04:00
major refactoring of preact components 🧩 (#689)
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import "./src/dark-mode.js";
|
import "./src/theme.js";
|
||||||
import "./src/emoji.js";
|
import "./src/emoji.js";
|
||||||
import "./src/hits.js";
|
import "./src/hits.js";
|
||||||
import "./src/clipboard.js";
|
import "./src/clipboard.js";
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
// Heavily inspired by AnchorJS:
|
import { h, render } from "preact";
|
||||||
// https://github.com/bryanbraun/anchorjs
|
|
||||||
|
|
||||||
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
|
// loop through each h2, h3, h4 in this page's content area
|
||||||
// prettier-ignore
|
// prettier-ignore
|
||||||
@@ -9,24 +9,15 @@ document.querySelectorAll([
|
|||||||
"div#content h2",
|
"div#content h2",
|
||||||
"div#content h3",
|
"div#content h3",
|
||||||
"div#content h4",
|
"div#content h4",
|
||||||
]).forEach((h) => {
|
]).forEach((heading) => {
|
||||||
// don't add to elements without a pre-existing ID (e.g. `<h2 id="...">`)
|
// don't add to elements without a pre-existing ID (e.g. `<h2 id="...">`)
|
||||||
if (!h.hasAttribute("id")) {
|
if (!heading.hasAttribute("id")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// build the anchor link (the "#" icon is added via CSS)
|
// TODO: little hacky hack to make the anchor appear AFTER the existing h tag
|
||||||
const anchor = document.createElement("a");
|
const linkTarget = document.createElement("a");
|
||||||
anchor.className = "anchorjs-link";
|
heading.appendChild(linkTarget);
|
||||||
anchor.href = `#${h.getAttribute("id")}`;
|
|
||||||
anchor.ariaLabel = "Anchor";
|
|
||||||
|
|
||||||
// if this is a touchscreen, always show the "#" icon instead waiting for hover
|
render(<Anchor id={heading.getAttribute("id")} title={heading.textContent.trim()} />, heading, linkTarget);
|
||||||
// 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);
|
|
||||||
});
|
});
|
||||||
|
1
assets/js/src/assets/bulb-off.svg
Normal file
1
assets/js/src/assets/bulb-off.svg
Normal 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
assets/js/src/assets/bulb-on.svg
Normal file
1
assets/js/src/assets/bulb-on.svg
Normal 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 |
@@ -1,43 +1,7 @@
|
|||||||
import { h, render } from "preact";
|
import { h, render } from "preact";
|
||||||
import { useState } from "preact/hooks";
|
|
||||||
import copy from "clipboard-copy";
|
|
||||||
import trimNewlines from "trim-newlines";
|
|
||||||
|
|
||||||
// shared react components:
|
// react components:
|
||||||
import { CopyIcon, CheckIcon } from "@primer/octicons-react";
|
import CopyButton from "./components/CopyButton.js";
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// loop through each code fence on page (if any)
|
// loop through each code fence on page (if any)
|
||||||
document.querySelectorAll("div.highlight").forEach((highlightDiv) => {
|
document.querySelectorAll("div.highlight").forEach((highlightDiv) => {
|
||||||
|
21
assets/js/src/components/Anchor.js
Normal file
21
assets/js/src/components/Anchor.js
Normal 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
assets/js/src/components/ContactForm.js
Normal file
154
assets/js/src/components/ContactForm.js
Normal 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
assets/js/src/components/CopyButton.js
Normal file
42
assets/js/src/components/CopyButton.js
Normal 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
assets/js/src/components/Counter.js
Normal file
31
assets/js/src/components/Counter.js
Normal 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;
|
79
assets/js/src/components/RepositoryCard.js
Normal file
79
assets/js/src/components/RepositoryCard.js
Normal 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;
|
36
assets/js/src/components/RepositoryGrid.js
Normal file
36
assets/js/src/components/RepositoryGrid.js
Normal 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;
|
36
assets/js/src/components/ThemeToggle.js
Normal file
36
assets/js/src/components/ThemeToggle.js
Normal 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;
|
@@ -1,158 +1,9 @@
|
|||||||
import { h, Fragment, render } from "preact";
|
import { h, render } from "preact";
|
||||||
import { useState } from "preact/hooks";
|
|
||||||
import fetch from "unfetch";
|
|
||||||
|
|
||||||
// shared react components:
|
// react components:
|
||||||
import HCaptcha from "@hcaptcha/react-hcaptcha";
|
import ContactForm from "./components/ContactForm.js";
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// don't continue if there isn't a contact form on this page
|
// don't continue if there isn't a contact form on this page
|
||||||
if (typeof window !== "undefined" && document.querySelector("div#contact-form-wrapper")) {
|
if (typeof window !== "undefined" && document.querySelector(".layout-contact #contact-form-wrapper")) {
|
||||||
render(<ContactForm />, document.querySelector("div#contact-form-wrapper"));
|
render(<ContactForm />, document.querySelector(".layout-contact #contact-form-wrapper"));
|
||||||
}
|
}
|
||||||
|
@@ -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,11 +1,4 @@
|
|||||||
import * as imagemoji from "imagemoji";
|
import parseEmoji from "./utils/parseEmoji.js";
|
||||||
|
|
||||||
const parseEmoji = (what) =>
|
|
||||||
// we're hosting twemojis locally instead of from Twitter's CDN
|
|
||||||
imagemoji.parse(what, (icon) => `/assets/emoji/${icon}.svg`);
|
|
||||||
|
|
||||||
// apply to the entire body automatically on load...
|
// apply to the entire body automatically on load...
|
||||||
parseEmoji(document.body);
|
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;
|
|
||||||
|
@@ -1,39 +1,11 @@
|
|||||||
import { h, render } from "preact";
|
import { h, render } from "preact";
|
||||||
import { useState, useEffect } from "preact/hooks";
|
|
||||||
import fetch from "unfetch";
|
|
||||||
import canonicalUrl from "get-canonical-url";
|
import canonicalUrl from "get-canonical-url";
|
||||||
|
|
||||||
// shared react components:
|
// react components:
|
||||||
import Loading from "./components/loading.js";
|
import Counter from "./components/Counter.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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// page must have a div#meta-hits-counter element to continue
|
// 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
|
// use <link rel="canonical"> to deduce a consistent identifier for this page
|
||||||
const canonical = canonicalUrl({
|
const canonical = canonicalUrl({
|
||||||
normalize: true,
|
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
|
// get path and strip beginning and ending forward slash
|
||||||
const slug = new URL(canonical).pathname.replace(/^\/|\/$/g, "") || "/";
|
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"));
|
||||||
}
|
}
|
||||||
|
@@ -1,114 +1,9 @@
|
|||||||
import { h, Fragment, render } from "preact";
|
import { h, 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";
|
|
||||||
|
|
||||||
// shared react components:
|
// react components:
|
||||||
import { StarIcon, RepoForkedIcon } from "@primer/octicons-react";
|
import RepositoryGrid from "./components/RepositoryGrid.js";
|
||||||
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>
|
|
||||||
);
|
|
||||||
|
|
||||||
// detect if these cards are wanted on this page (only /projects)
|
// detect if these cards are wanted on this page (only /projects)
|
||||||
if (typeof window !== "undefined" && document.querySelector("div#github-cards")) {
|
if (typeof window !== "undefined" && document.querySelector(".layout-projects #github-cards")) {
|
||||||
// dayjs plugins: https://day.js.org/docs/en/plugin/loading-into-nodejs
|
render(<RepositoryGrid />, document.querySelector(".layout-projects #github-cards"));
|
||||||
dayjs.extend(dayjsRelativeTime);
|
|
||||||
|
|
||||||
render(<RepositoryGrid />, document.querySelector("div#github-cards"));
|
|
||||||
}
|
}
|
||||||
|
29
assets/js/src/theme.js
Normal file
29
assets/js/src/theme.js
Normal 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
assets/js/src/utils/getData.js
Normal file
8
assets/js/src/utils/getData.js
Normal 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
assets/js/src/utils/parseEmoji.js
Normal file
8
assets/js/src/utils/parseEmoji.js
Normal 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
assets/js/src/utils/theme.js
Normal file
19
assets/js/src/utils/theme.js
Normal 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");
|
||||||
|
}
|
||||||
|
};
|
@@ -60,7 +60,3 @@ $themes: (
|
|||||||
error: #ff5151,
|
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>';
|
|
||||||
|
@@ -32,7 +32,7 @@ div#content {
|
|||||||
letter-spacing: 0.001em;
|
letter-spacing: 0.001em;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
|
|
||||||
&:hover > a.anchorjs-link {
|
&:hover > .anchorjs-link {
|
||||||
opacity: 1; // '#' link appears on hover over entire sub-heading line
|
opacity: 1; // '#' link appears on hover over entire sub-heading line
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -50,13 +50,14 @@ div#content {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AnchorJS styles
|
// AnchorJS styles
|
||||||
a.anchorjs-link {
|
.anchorjs-link {
|
||||||
margin-left: 0.25em;
|
margin: 0 0.25em;
|
||||||
padding: 0 0.5em 0 0.25em;
|
padding: 0 0.25em;
|
||||||
background: none;
|
background: none;
|
||||||
opacity: 0;
|
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
opacity: 0; // overridden by JS on mobile devices
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
content: "\0023"; // pound sign
|
content: "\0023"; // pound sign
|
||||||
|
@@ -24,15 +24,6 @@ body {
|
|||||||
background-color: "background-outer",
|
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,
|
code,
|
||||||
@@ -141,8 +132,7 @@ main {
|
|||||||
|
|
||||||
// make SVG twemojis relative to surrounding text
|
// make SVG twemojis relative to surrounding text
|
||||||
// https://github.com/twitter/twemoji#inline-styles
|
// https://github.com/twitter/twemoji#inline-styles
|
||||||
img.emoji,
|
.emoji {
|
||||||
svg.emoji {
|
|
||||||
height: 1.2em;
|
height: 1.2em;
|
||||||
width: 1.2em;
|
width: 1.2em;
|
||||||
margin: 0 0.05em;
|
margin: 0 0.05em;
|
||||||
@@ -160,16 +150,12 @@ a .emoji {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// pulsating loading spinner
|
// pulsating loading spinner
|
||||||
.loading {
|
div.loading > div {
|
||||||
display: inline-block;
|
@include themes.themed(
|
||||||
|
(
|
||||||
> div {
|
background-color: "medium-light",
|
||||||
@include themes.themed(
|
)
|
||||||
(
|
);
|
||||||
background-color: "medium-light",
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Responsive
|
// Responsive
|
||||||
|
@@ -69,7 +69,7 @@ header {
|
|||||||
ul {
|
ul {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: center;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
||||||
@@ -78,6 +78,7 @@ header {
|
|||||||
|
|
||||||
a {
|
a {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
@include themes.themed(
|
@include themes.themed(
|
||||||
(
|
(
|
||||||
@@ -94,11 +95,8 @@ header {
|
|||||||
}
|
}
|
||||||
|
|
||||||
span {
|
span {
|
||||||
align-self: center;
|
|
||||||
|
|
||||||
&.header-menu-icon {
|
&.header-menu-icon {
|
||||||
font-size: 1.3em;
|
font-size: 1.3em;
|
||||||
vertical-align: -0.085em;
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,22 +110,16 @@ header {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Dark mode toggle
|
// Dark mode toggle
|
||||||
&#header-lightbulb {
|
&.theme-toggle button {
|
||||||
align-self: center;
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
margin: 0.3em -0.3em 0 1.4em;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
button {
|
svg {
|
||||||
// native button reset
|
|
||||||
border: 0;
|
|
||||||
padding: 0;
|
|
||||||
width: 1.56em; // 24.33px, don't ask
|
width: 1.56em; // 24.33px, don't ask
|
||||||
height: 1.56em;
|
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
|
// Dark mode toggle
|
||||||
&#header-lightbulb button.dark-mode-toggle {
|
&.theme-toggle button {
|
||||||
width: 1.08em; // ~27px, don't ask
|
|
||||||
height: 1.08em;
|
|
||||||
margin-left: 1em;
|
margin-left: 1em;
|
||||||
margin-right: -0.2em; // weirdness w/ svg ratio
|
margin-right: -0.2em; // weirdness w/ svg ratio
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 1.08em; // ~27px, don't ask
|
||||||
|
height: 1.08em;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -16,9 +16,7 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{{- end }}
|
{{- end }}
|
||||||
<li id="header-lightbulb">
|
<li class="theme-toggle"></li>
|
||||||
<button class="dark-mode-toggle" title="Toggle Dark Mode" aria-label="Toggle Dark Mode" style="display: none;"></button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
10
package.json
10
package.json
@@ -30,7 +30,7 @@
|
|||||||
"@fontsource/roboto-mono": "4.5.0",
|
"@fontsource/roboto-mono": "4.5.0",
|
||||||
"@hcaptcha/react-hcaptcha": "^0.3.9",
|
"@hcaptcha/react-hcaptcha": "^0.3.9",
|
||||||
"@octokit/graphql": "^4.8.0",
|
"@octokit/graphql": "^4.8.0",
|
||||||
"@primer/octicons-react": "^16.1.1",
|
"@primer/octicons-react": "^16.2.0",
|
||||||
"@sentry/node": "^6.16.1",
|
"@sentry/node": "^6.16.1",
|
||||||
"clipboard-copy": "^4.0.1",
|
"clipboard-copy": "^4.0.1",
|
||||||
"dayjs": "^1.10.7",
|
"dayjs": "^1.10.7",
|
||||||
@@ -52,10 +52,10 @@
|
|||||||
"unfetch": "^4.2.0"
|
"unfetch": "^4.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.16.0",
|
"@babel/core": "^7.16.5",
|
||||||
"@babel/eslint-parser": "^7.16.3",
|
"@babel/eslint-parser": "^7.16.5",
|
||||||
"@babel/preset-env": "^7.16.4",
|
"@babel/preset-env": "^7.16.5",
|
||||||
"@babel/preset-react": "^7.16.0",
|
"@babel/preset-react": "^7.16.5",
|
||||||
"@jakejarvis/eslint-config": "github:jakejarvis/eslint-config#main",
|
"@jakejarvis/eslint-config": "github:jakejarvis/eslint-config#main",
|
||||||
"@svgr/webpack": "^6.1.2",
|
"@svgr/webpack": "^6.1.2",
|
||||||
"autoprefixer": "^10.4.0",
|
"autoprefixer": "^10.4.0",
|
||||||
|
@@ -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>
|
|
Reference in New Issue
Block a user