use preact for common components across site (#663)

* convert GitHub cards grid from lit-html to preact

* give hit counter the preact treatment

* extract loading spinner component to a shared location

* move *some* loading spinner styles to its JSX

* Update .percy.yml

* pick up images in JS w/ webpack

* pull star/fork icons straight from @primer/octicons

* a bit of cleanup

* check `typeof window !== "undefined"` before rendering

* bump misc. deps

* silence missing license warnings for preact-hooks and preact-compat

* add source-map-loader

* Update loading.js
This commit is contained in:
jake authored and GitHub committed 2021-11-24 13:51:29 -05:00
1 parent 9b3ae0f62a
commit b755b66d19
20 files changed
+541 -315

No files matched your search

+43
View File
@@ -0,0 +1,43 @@
import { h } from "preact";
const Loading = (props) => {
// allow a custom number of pulsing boxes (defaults to 3)
const boxes = props.boxes || 3;
// each individual box's animation has a staggered start in corresponding order
const animationTiming = props.timing || 0.1; // seconds
// each box is just an empty div
const divs = [];
for (let i = 0; i < boxes; i++) {
divs.push(
<div
style={{
// width of each box correlates with number of boxes (with a little padding)
width: `${props.width / (boxes + 1)}px`,
height: "100%",
display: "inline-block",
// see assets/sass/components/_animation.scss:
animation: "loading 1.5s infinite ease-in-out both",
"animation-delay": `${i * animationTiming}s`,
}}
/>
);
}
return (
<div
class="loading"
style={{
width: `${props.width}px`,
height: `${props.width / 2}px`,
display: "inline-block",
"text-align": "center",
...props.style,
}}
>
{divs}
</div>
);
};
export default Loading;
+1 -1
View File
@@ -13,7 +13,7 @@ disableTransitionCSSHack.sheet.insertRule(`
initDarkMode({
toggle: document.querySelector(".dark-mode-toggle"),
onInit: function (t) {
onInit: (t) => {
// make toggle visible now that we know JS is enabled
t.style.display = "block";
+31 -29
View File
@@ -1,14 +1,42 @@
import { h, render } from "preact";
import { useState, useEffect } from "preact/hooks";
import fetch from "cross-fetch";
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>
);
};
// don't continue if there isn't a span#meta-hits element on this page
const wrapper = document.querySelector("div#meta-hits");
const wrapper = document.querySelector("div#meta-hits-counter");
// page must have both span#meta-hits and canonical URL to enter
if (wrapper) {
if (typeof window !== "undefined" && wrapper) {
// use <link rel="canonical"> to deduce a consistent identifier for this page
const canonical = canonicalUrl({
normalize: true,
@@ -19,34 +47,8 @@ if (wrapper) {
},
});
// javascript is enabled so show the loading indicator
wrapper.style.display = "inline-flex";
// get path and strip beginning and ending forward slash
const slug = new URL(canonical).pathname.replace(/^\/|\/$/g, "");
fetch(`${HITS_ENDPOINT}?slug=${encodeURIComponent(slug)}`)
.then((response) => response.json())
.then((data) => {
// pretty number and units
const hitsComma = data.hits.toLocaleString("en-US");
const hitsPlural = data.hits === 1 ? "view" : "views";
wrapper.title = `${hitsComma} ${hitsPlural}`;
// finally inject the hits...
const counter = document.querySelector("span#meta-hits-counter");
if (counter) {
counter.append(hitsComma);
}
// ...and hide the loading spinner
const spinner = document.querySelector("div#meta-hits-loading");
if (spinner) {
spinner.remove();
}
})
.catch(() => {
// something went horribly wrong, initiate coverup
wrapper.remove();
});
render(<Counter slug={slug} />, wrapper);
}
+85 -78
View File
@@ -1,93 +1,100 @@
import { h, render, Fragment } from "preact";
import { useState, useEffect } from "preact/hooks";
import fetch from "cross-fetch";
import { render } from "lit-html";
import { html } from "lit-html/static.js";
import { ifDefined } from "lit-html/directives/if-defined.js";
import dayjs from "dayjs";
import dayjsLocalizedFormat from "dayjs/plugin/localizedFormat.js";
import dayjsRelativeTime from "dayjs/plugin/relativeTime.js";
import { parse as parseEmoji } from "imagemoji";
// 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";
// don't continue if there isn't a span#meta-hits element on this page
// TODO: be better.
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 (
<Fragment>
{repos.map((repo) => (
// eslint-disable-next-line react/jsx-key
<RepositoryCard {...repo} />
))}
</Fragment>
);
};
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, (icon) => `/assets/emoji/${icon}.svg`) }}
/>
)}
<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"
title={`${repo.stars.toLocaleString("en-US")} ${repo.stars === 1 ? "star" : "stars"}`}
>
<StarIcon size={16} fill="currentColor" />
<span>{repo.stars.toLocaleString("en-US")}</span>
</div>
)}
{repo.forks > 0 && (
<div
class="repo-meta-item"
title={`${repo.forks.toLocaleString("en-US")} ${repo.forks === 1 ? "fork" : "forks"}`}
>
<RepoForkedIcon size={16} fill="currentColor" />
<span>{repo.forks.toLocaleString("en-US")}</span>
</div>
)}
<div class="repo-meta-item" title={dayjs(repo.updatedAt).format("lll Z")}>
<span>Updated {dayjs(repo.updatedAt).fromNow()}</span>
</div>
</div>
</div>
);
// detect if these cards are wanted on this page (only /projects)
const wrapper = document.querySelector("div#github-cards");
if (wrapper) {
if (typeof window !== "undefined" && wrapper) {
// dayjs plugins: https://day.js.org/docs/en/plugin/loading-into-nodejs
dayjs.extend(dayjsLocalizedFormat);
dayjs.extend(dayjsRelativeTime);
// this is a total sh*tshow, but safer than setting one big string via innerHTML :)
// TODO: consider making this a real LitElement?
const template = (repo) => html`
<a class="repo-name" href="${repo.url}" target="_blank" rel="noopener">${repo.name}</a>
${repo.description ? html`<p class="repo-description">${repo.description}</p>` : null}
<div class="repo-meta">
${repo.language
? html`<div class="repo-meta-item">
<span class="repo-language-color" style="background-color: ${ifDefined(repo.language.color)}"></span>
<span>${repo.language.name}</span>
</div>`
: null}
${repo.stars > 0
? html`<div
class="repo-meta-item"
title="${repo.stars.toLocaleString("en-US")} ${repo.stars === 1 ? "star" : "stars"}"
>
<svg viewBox="0 0 16 16" height="16" width="16">
<path
fill-rule="evenodd"
d="M8 .25a.75.75 0 01.673.418l1.882 3.815 4.21.612a.75.75 0 01.416 1.279l-3.046 2.97.719 4.192a.75.75 0 01-1.088.791L8 12.347l-3.766 1.98a.75.75 0 01-1.088-.79l.72-4.194L.818 6.374a.75.75 0 01.416-1.28l4.21-.611L7.327.668A.75.75 0 018 .25zm0 2.445L6.615 5.5a.75.75 0 01-.564.41l-3.097.45 2.24 2.184a.75.75 0 01.216.664l-.528 3.084 2.769-1.456a.75.75 0 01.698 0l2.77 1.456-.53-3.084a.75.75 0 01.216-.664l2.24-2.183-3.096-.45a.75.75 0 01-.564-.41L8 2.694v.001z"
></path>
</svg>
<span>${repo.stars.toLocaleString("en-US")}</span>
</div>`
: null}
${repo.forks > 0
? html`<div
class="repo-meta-item"
title="${repo.forks.toLocaleString("en-US")} ${repo.forks === 1 ? "fork" : "forks"}"
>
<svg viewBox="0 0 16 16" height="16" width="16">
<path
fill-rule="evenodd"
d="M5 3.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm0 2.122a2.25 2.25 0 10-1.5 0v.878A2.25 2.25 0 005.75 8.5h1.5v2.128a2.251 2.251 0 101.5 0V8.5h1.5a2.25 2.25 0 002.25-2.25v-.878a2.25 2.25 0 10-1.5 0v.878a.75.75 0 01-.75.75h-4.5A.75.75 0 015 6.25v-.878zm3.75 7.378a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm3-8.75a.75.75 0 100-1.5.75.75 0 000 1.5z"
></path>
</svg>
<span>${repo.forks.toLocaleString("en-US")}</span>
</div>`
: null}
<div class="repo-meta-item" title="${dayjs(repo.updatedAt).format("lll Z")}">
<span>Updated ${dayjs(repo.updatedAt).fromNow()}</span>
</div>
</div>
`;
fetch(PROJECTS_ENDPOINT)
.then((response) => response.json())
.then((data) => {
data.forEach((repo) => {
const div = document.createElement("div");
div.classList.add("github-card");
render(template(repo), div);
wrapper.append(div);
});
// we're done, hide the loading spinner
const spinner = document.querySelector("div.loading");
if (spinner) {
spinner.remove();
}
// the repo descriptions were added after the first twemoji parsing
parseEmoji(wrapper, (icon) => `/assets/emoji/${icon}.svg`);
})
.catch(() => {
// something went horribly wrong, initiate coverup
wrapper.remove();
});
render(<RepositoryGrid />, wrapper);
}
+1 -28
View File
@@ -15,34 +15,6 @@
will-change: transform;
}
// modified from https://tobiasahlin.com/spinkit/
.loading {
text-align: center;
> div {
display: inline-block;
animation: loading 1.5s infinite ease-in-out both;
// stylelint-disable rule-empty-line-before
&:nth-child(1) {
animation-delay: -0.32s;
}
&:nth-child(2) {
animation-delay: -0.16s;
}
&:nth-child(3) {
animation-delay: 0s;
}
// stylelint-enable rule-empty-line-before
@include themes.themed(
(
background-color: "medium-light",
)
);
}
}
@keyframes wave {
// stylelint-disable rule-empty-line-before
0% {
@@ -99,6 +71,7 @@
// stylelint-enable rule-empty-line-before
}
// modified from https://tobiasahlin.com/spinkit/
@keyframes loading {
// stylelint-disable rule-empty-line-before
0%,
+13
View File
@@ -161,6 +161,19 @@ a img.emoji {
cursor: inherit;
}
// pulsating loading spinner
.loading {
display: inline-block;
> div {
@include themes.themed(
(
background-color: "medium-light",
)
);
}
}
// Responsive
@mixin responsive() {
body {
+8 -25
View File
@@ -14,17 +14,6 @@ div.layout-projects {
text-align: center;
}
div#loading-spinner {
width: 40px;
display: block;
margin: 1.2em auto 0 auto;
> div {
width: 10px;
height: 20px;
}
}
div#github-cards {
display: flex;
flex-flow: row wrap;
@@ -34,7 +23,6 @@ div.layout-projects {
div.github-card {
flex-grow: 1;
display: block;
width: 416px; // magic number
padding: 1em 1.2em;
margin: 0.6em;
@@ -55,7 +43,7 @@ div.layout-projects {
}
p.repo-description {
margin: 0.3em 0 0.6em 0;
margin: 0.2em 0 0.8em 0;
}
div.repo-meta {
@@ -63,8 +51,6 @@ div.layout-projects {
flex-wrap: wrap;
div.repo-meta-item {
display: inline-block;
align-self: baseline;
margin-right: 1.5em;
font-size: 0.925em;
@@ -74,22 +60,19 @@ div.layout-projects {
)
);
svg {
display: inline-block;
position: relative;
top: 0.25em;
margin-right: 0.2em;
fill: currentColor;
.octicon,
span.repo-language-color {
margin-right: 0.5em;
}
span.repo-language-color {
display: inline-block;
width: 1.2em;
height: 1.2em;
width: 1.15em;
height: 1.15em;
border-radius: 50%;
position: relative;
top: 0.235em;
margin-right: 0.2em;
top: 0.175em;
margin-right: 0.5em;
}
}
}
-10
View File
@@ -81,16 +81,6 @@ div.layout-single {
}
}
}
div#meta-hits-loading {
display: inline-block;
width: 20px;
> div {
width: 5px;
height: 10px;
}
}
}
h1.title {