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/hits.js";
|
||||
import "./src/clipboard.js";
|
||||
|
@@ -1,7 +1,7 @@
|
||||
// Heavily inspired by AnchorJS:
|
||||
// https://github.com/bryanbraun/anchorjs
|
||||
import { h, render } from "preact";
|
||||
|
||||
import isTouchDevice from "is-touch-device";
|
||||
// react components:
|
||||
import Anchor from "./components/Anchor.js";
|
||||
|
||||
// loop through each h2, h3, h4 in this page's content area
|
||||
// prettier-ignore
|
||||
@@ -9,24 +9,15 @@ document.querySelectorAll([
|
||||
"div#content h2",
|
||||
"div#content h3",
|
||||
"div#content h4",
|
||||
]).forEach((h) => {
|
||||
]).forEach((heading) => {
|
||||
// don't add to elements without a pre-existing ID (e.g. `<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
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 { 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
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 { 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"));
|
||||
}
|
||||
|
@@ -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";
|
||||
|
||||
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;
|
||||
|
@@ -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"));
|
||||
}
|
||||
|
@@ -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
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,
|
||||
),
|
||||
);
|
||||
|
||||
// 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;
|
||||
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
|
||||
|
@@ -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 {
|
||||
div.loading > div {
|
||||
@include themes.themed(
|
||||
(
|
||||
background-color: "medium-light",
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive
|
||||
|
@@ -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;
|
||||
|
||||
button {
|
||||
// native button reset
|
||||
&.theme-toggle button {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
width: 1.56em; // 24.33px, don't ask
|
||||
height: 1.56em;
|
||||
margin: -0.075em -0.3em 0 1.4em; // weirdness w/ svg ratio
|
||||
background: none;
|
||||
margin: 0.3em -0.3em 0 1.4em;
|
||||
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%;
|
||||
svg {
|
||||
width: 1.56em; // 24.33px, don't ask
|
||||
height: 1.56em;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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>
|
||||
|
10
package.json
10
package.json
@@ -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",
|
||||
|
@@ -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