1
mirror of https://github.com/jakejarvis/jarv.is.git synced 2025-06-30 22:26:38 -04:00
* gymnastics to make theme script work with react 18 hydration

* try next 12.1.3 canary to fix SSG head tags?

* revert theme script changes

* next 12.1.3-canary.3

* double-revert some of the use-theme.tsx changes

* separate theme restoration script & move to _document

* bump next

* bump next (again)

* clean up some theme stuff

* use hashed image URLs in webmanifest and feeds

* text experimental react config

* Update ThemeScript.tsx

* switch selfie image to `layout="raw"`

* use `layout="raw"` for all non-imported images

* revert raw images in some places, messes up responsiveness

* fix nitpicky "no divs inside buttons" html validation error

* fix react-player hydration errors

* fix hydration errors from server/client time zone differences

* clean up hydration fixes

* Update format-date.ts

* last-minute cleanup
This commit is contained in:
2022-04-06 09:37:16 -04:00
committed by GitHub
parent 0a741b4282
commit eccf2108c7
29 changed files with 575 additions and 541 deletions

View File

@ -1,6 +1,6 @@
import { forwardRef, useState, useEffect } from "react";
import copy from "copy-to-clipboard";
import innerText from "react-innertext";
import copy from "copy-to-clipboard";
import { ClipboardOcticon, CheckOcticon } from "../Icons";
import { styled } from "../../lib/styles/stitches.config";
import type { ReactNode, Ref } from "react";

View File

@ -1,5 +1,5 @@
import Image, { CustomImageProps } from "../Image";
import innerText from "react-innertext";
import Image, { CustomImageProps } from "../Image";
import { styled } from "../../lib/styles/stitches.config";
import type { PropsWithChildren } from "react";

View File

@ -1,6 +1,7 @@
import useSWR from "swr";
import Loading from "../Loading";
import { fetcher } from "../../lib/helpers/fetcher";
import { siteLocale } from "../../lib/config";
export type HitCounterProps = {
slug: string;
@ -27,8 +28,11 @@ const HitCounter = ({ slug, className }: HitCounterProps) => {
// we have data!
return (
<span title={`${data.hits.toLocaleString("en-US")} ${data.hits === 1 ? "view" : "views"}`} className={className}>
{data.hits.toLocaleString("en-US")}
<span
title={`${data.hits.toLocaleString(siteLocale)} ${data.hits === 1 ? "view" : "views"}`}
className={className}
>
{data.hits.toLocaleString(siteLocale)}
</span>
);
} catch (error) {

View File

@ -36,8 +36,8 @@ const CustomImage = ({
}: CustomImageProps) => {
// passed directly into next/image: https://nextjs.org/docs/api-reference/next/image
const imageProps: Partial<NextImageProps> = {
width: typeof width === "string" ? Number.parseInt(width) : width,
height: typeof height === "string" ? Number.parseInt(height) : height,
width: typeof width === "string" ? Number.parseInt(width.replace("px", "")) : width,
height: typeof height === "string" ? Number.parseInt(height.replace("px", "")) : height,
alt: alt || "",
layout: layout || "intrinsic",
quality: quality || 65,

View File

@ -2,7 +2,8 @@ import Head from "next/head";
import Header from "../Header";
import Footer from "../Footer";
import { useTheme } from "../../hooks/use-theme";
import { styled, theme, darkTheme } from "../../lib/styles/stitches.config";
import { styled } from "../../lib/styles/stitches.config";
import { themeColors } from "../../lib/styles/helpers/themes";
import type { ComponentProps } from "react";
const Flex = styled("div", {
@ -39,10 +40,7 @@ const Layout = ({ container = true, stickyHeader = true, children, ...rest }: La
<>
<Head>
{/* dynamically set browser theme color to match the background color; default to light for SSR */}
<meta
name="theme-color"
content={(resolvedTheme === "dark" ? darkTheme : theme).colors.backgroundOuter?.value}
/>
<meta name="theme-color" content={themeColors[resolvedTheme === "dark" ? "dark" : "light"]} />
</Head>
<Flex {...rest}>

View File

@ -1,8 +1,8 @@
import Link from "next/link";
import { format } from "date-fns";
import HitCounter from "../HitCounter";
import NoteTitle from "../NoteTitle";
import { DateIcon, TagIcon, EditIcon, ViewsIcon } from "../Icons";
import { formatDateTZ } from "../../lib/helpers/format-date";
import { styled } from "../../lib/styles/stitches.config";
import * as config from "../../lib/config";
import type { NoteType } from "../../types";
@ -66,7 +66,7 @@ const NoteMeta = ({ slug, date, title, htmlTitle, tags = [] }: NoteMetaProps) =>
<span>
<Icon as={DateIcon} />
</span>
<span title={format(new Date(date), "PPppp")}>{format(new Date(date), "MMMM d, yyyy")}</span>
<span title={formatDateTZ(date)}>{formatDateTZ(date, "MMMM d, yyyy")}</span>
</MetaLink>
</Link>
</MetaItem>

View File

@ -1,5 +1,5 @@
import { format } from "date-fns";
import Link from "../Link";
import { formatDateTZ } from "../../lib/helpers/format-date";
import { styled } from "../../lib/styles/stitches.config";
import type { NoteType } from "../../types";
@ -67,7 +67,7 @@ const NotesList = ({ notesByYear }: NotesListProps) => {
<List>
{notes.map(({ slug, date, htmlTitle }) => (
<Post key={slug}>
<PostDate>{format(new Date(date), "MMM d")}</PostDate>
<PostDate title={formatDateTZ(date)}>{formatDateTZ(date, "MMM d")}</PostDate>
<span>
<Link
href={{

View File

@ -1,7 +1,9 @@
import { intlFormat, formatDistanceToNowStrict } from "date-fns";
import Link from "../Link";
import { StarOcticon, ForkOcticon } from "../Icons";
import { useHasMounted } from "../../hooks/use-has-mounted";
import { formatDateTZ, formatTimeAgo } from "../../lib/helpers/format-date";
import { styled } from "../../lib/styles/stitches.config";
import { siteLocale } from "../../lib/config";
import type { RepositoryType } from "../../types";
const Wrapper = styled("div", {
@ -80,68 +82,58 @@ const RepositoryCard = ({
forks,
updatedAt,
className,
}: RepositoryCardProps) => (
<Wrapper className={className}>
<Name href={url}>{name}</Name>
}: RepositoryCardProps) => {
const hasMounted = useHasMounted();
{description && <Description>{description}</Description>}
return (
<Wrapper className={className}>
<Name href={url}>{name}</Name>
<Meta>
{language && (
<MetaItem>
<LanguageCircle css={{ backgroundColor: language.color }} />
<span>{language.name}</span>
</MetaItem>
)}
{description && <Description>{description}</Description>}
{stars > 0 && (
<MetaItem>
<MetaLink
href={`${url}/stargazers`}
title={`${stars.toLocaleString("en-US")} ${stars === 1 ? "star" : "stars"}`}
target="_blank"
rel="noopener noreferrer"
>
<MetaIcon as={StarOcticon} />
<span>{stars.toLocaleString("en-US")}</span>
</MetaLink>
</MetaItem>
)}
{forks > 0 && (
<MetaItem>
<MetaLink
href={`${url}/network/members`}
title={`${forks.toLocaleString("en-US")} ${forks === 1 ? "fork" : "forks"}`}
target="_blank"
rel="noopener noreferrer"
>
<MetaIcon as={ForkOcticon} />
<span>{forks.toLocaleString("en-US")}</span>
</MetaLink>
</MetaItem>
)}
<MetaItem
title={intlFormat(
new Date(updatedAt),
{
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "numeric",
timeZoneName: "short",
},
{
locale: "en-US",
}
<Meta>
{language && (
<MetaItem>
<LanguageCircle css={{ backgroundColor: language.color }} />
<span>{language.name}</span>
</MetaItem>
)}
>
<span>Updated {formatDistanceToNowStrict(new Date(updatedAt), { addSuffix: true })}</span>
</MetaItem>
</Meta>
</Wrapper>
);
{stars > 0 && (
<MetaItem>
<MetaLink
href={`${url}/stargazers`}
title={`${stars.toLocaleString(siteLocale)} ${stars === 1 ? "star" : "stars"}`}
target="_blank"
rel="noopener noreferrer"
>
<MetaIcon as={StarOcticon} />
<span>{stars.toLocaleString(siteLocale)}</span>
</MetaLink>
</MetaItem>
)}
{forks > 0 && (
<MetaItem>
<MetaLink
href={`${url}/network/members`}
title={`${forks.toLocaleString(siteLocale)} ${forks === 1 ? "fork" : "forks"}`}
target="_blank"
rel="noopener noreferrer"
>
<MetaIcon as={ForkOcticon} />
<span>{forks.toLocaleString(siteLocale)}</span>
</MetaLink>
</MetaItem>
)}
{/* only use relative "time ago" on client side, since it'll be outdated via SSG and cause hydration errors */}
<MetaItem title={formatDateTZ(updatedAt)}>
<span>Updated {hasMounted ? formatTimeAgo(updatedAt) : `on ${formatDateTZ(updatedAt, "PP")}`}</span>
</MetaItem>
</Meta>
</Wrapper>
);
};
export default RepositoryCard;

View File

@ -6,23 +6,16 @@ import type { ComponentProps } from "react";
import selfieJpg from "../../public/static/images/selfie.jpg";
const ConstrainImage = styled("div", {
const Image = styled(NextImage, {
display: "block",
width: "50px",
height: "50px",
lineHeight: 0,
padding: 0,
"@medium": {
width: "70px",
height: "70px",
},
});
const Image = styled(NextImage, {
border: "1px solid $light !important",
borderRadius: "50%",
"@medium": {
width: "70px",
height: "70px",
borderWidth: "2px !important",
},
});
@ -60,17 +53,7 @@ export type SelfieProps = ComponentProps<typeof Link>;
const Selfie = ({ ...rest }: SelfieProps) => (
<NextLink href="/" passHref={true}>
<Link {...rest}>
<ConstrainImage>
<Image
src={selfieJpg}
alt="Photo of Jake Jarvis"
width={70}
height={70}
quality={60}
layout="intrinsic"
priority
/>
</ConstrainImage>
<Image src={selfieJpg} alt="Photo of Jake Jarvis" width={50} height={50} quality={60} layout="raw" priority />
<Name>Jake Jarvis</Name>
</Link>
</NextLink>

View File

@ -0,0 +1,62 @@
import { darkModeQuery, themeStorageKey, themeClassNames } from "../../lib/styles/helpers/themes";
// comments are up here to avoid having them inside the actual client output:
// - `p` is the user's saved preference
// - `c` is the map of theme -> classname
// - `l` is the list of <html>'s current class(es), which the `cn` values are removed to start fresh
// - `q` is always the CSS media query for prefers dark mode
// - `m` is the listener which tests that media query
// - `try/catch` is in case I messed something up here bigly... (will default to light theme)
/* eslint-disable no-empty, no-var, one-var */
const clientScript = () => {
try {
var p = localStorage.getItem("__STORAGE_KEY__"),
c = "__CLASS_NAMES__",
l = document.documentElement.classList;
l.remove("__LIST_OF_CLASSES__");
if (!p || p === "system") {
var q = "__MEDIA_QUERY__",
m = window.matchMedia(q);
m.media !== q || m.matches ? l.add(c["dark"]) : l.add(c["light"]);
} else {
l.add(c[p]);
}
} catch (e) {}
};
/* eslint-enable no-empty, no-var, one-var */
// since the function above will end up being injected as a plain dumb string, we need to set the dynamic values here:
const prepareScript = (script: unknown) => {
const functionString = String(script)
.replace('"__MEDIA_QUERY__"', `"${darkModeQuery}"`)
.replace('"__STORAGE_KEY__"', `"${themeStorageKey}"`)
.replace('"__CLASS_NAMES__"', JSON.stringify(themeClassNames))
.replace(
'"__LIST_OF_CLASSES__"',
Object.values(themeClassNames)
.map((t: string) => `"${t}"`)
.join(",")
);
// somewhat "minify" the final code by removing tabs/newlines:
// https://github.com/sindresorhus/condense-whitespace/blob/main/index.js
// .replace(/\s{2,}/gu, "")
// .trim();
// make it an IIFE:
return `(${functionString})()`;
};
// the script tag injected manually into `<head>` in _document.tsx.
// even though it's the proper method, using next/script with `strategy="beforeInteractive"` still causes flash of
// white on load. injecting a normal script tag lets us prioritize setting `<html>` attributes even more.
const ThemeScript = () => (
<script
key="restore-theme"
dangerouslySetInnerHTML={{
__html: prepareScript(clientScript),
}}
/>
);
export default ThemeScript;

View File

@ -0,0 +1,2 @@
export * from "./ThemeScript";
export { default } from "./ThemeScript";

View File

@ -92,10 +92,10 @@ const ThemeToggle = ({ id = "nav", className }: ThemeToggleProps) => {
config: springProperties.springConfig,
});
// render a dummy button until we're fully mounted and self-aware
// render a blank div of the same size to avoid layout shifting until we're fully mounted and self-aware
if (!hasMounted) {
return (
<Button aria-hidden={true} disabled={true}>
<Button as="div" aria-hidden={true}>
<div className={className} />
</Button>
);

View File

@ -1,16 +1,17 @@
import ReactPlayer from "react-player/file";
import { useHasMounted } from "../../hooks/use-has-mounted";
import { styled } from "../../lib/styles/stitches.config";
import type { FilePlayerProps } from "react-player/file";
const Wrapper = styled("div", {
position: "relative",
paddingTop: "56.25%",
});
"& > div": {
position: "absolute",
top: 0,
left: 0,
},
const Player = styled(ReactPlayer, {
position: "absolute",
top: 0,
left: 0,
"& video": {
borderRadius: "$rounded",
@ -30,6 +31,9 @@ export type VideoProps = Partial<FilePlayerProps> & {
};
const Video = ({ src, thumbnail, subs, autoplay, className, ...rest }: VideoProps) => {
// fix hydration issues: https://github.com/cookpete/react-player/issues/1428
const hasMounted = useHasMounted();
const url = [
src.webm && {
src: src.webm,
@ -73,15 +77,17 @@ const Video = ({ src, thumbnail, subs, autoplay, className, ...rest }: VideoProp
return (
<Wrapper className={className}>
<ReactPlayer
width="100%"
height="100%"
url={url}
controls={!autoplay}
// @ts-ignore
config={config}
{...rest}
/>
{hasMounted && (
<Player
width="100%"
height="100%"
url={url}
controls={!autoplay}
// @ts-ignore
config={config}
{...rest}
/>
)}
</Wrapper>
);
};

View File

@ -1,17 +1,19 @@
import ReactPlayer from "react-player/youtube";
import { useHasMounted } from "../../hooks/use-has-mounted";
import { styled } from "../../lib/styles/stitches.config";
import type { YouTubePlayerProps } from "react-player/youtube";
const Wrapper = styled("div", {
position: "relative",
paddingTop: "56.25%",
});
"& > div": {
position: "absolute",
top: 0,
left: 0,
},
const Player = styled(ReactPlayer, {
position: "absolute",
top: 0,
left: 0,
// target both the lazy thumbnail preview *and* the actual YouTube embed
"& .react-player__preview, & iframe": {
borderRadius: "$rounded",
},
@ -22,17 +24,24 @@ export type YouTubeEmbedProps = Partial<YouTubePlayerProps> & {
className?: string;
};
const YouTubeEmbed = ({ id, className, ...rest }: YouTubeEmbedProps) => (
<Wrapper className={className}>
<ReactPlayer
width="100%"
height="100%"
url={`https://www.youtube-nocookie.com/watch?v=${id}`}
light={`https://i.ytimg.com/vi/${id}/hqdefault.jpg`}
controls
{...rest}
/>
</Wrapper>
);
const YouTubeEmbed = ({ id, className, ...rest }: YouTubeEmbedProps) => {
// fix hydration issues: https://github.com/cookpete/react-player/issues/1428
const hasMounted = useHasMounted();
return (
<Wrapper className={className}>
{hasMounted && (
<Player
width="100%"
height="100%"
url={`https://www.youtube-nocookie.com/watch?v=${id}`}
light={`https://i.ytimg.com/vi/${id}/hqdefault.jpg`}
controls
{...rest}
/>
)}
</Wrapper>
);
};
export default YouTubeEmbed;