mirror of
https://github.com/jakejarvis/jarv.is.git
synced 2025-06-30 22:26:38 -04:00
React 18 (#863)
* 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:
@ -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";
|
||||
|
@ -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";
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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,
|
||||
|
@ -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}>
|
||||
|
@ -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>
|
||||
|
@ -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={{
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
|
62
components/ThemeScript/ThemeScript.tsx
Normal file
62
components/ThemeScript/ThemeScript.tsx
Normal 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;
|
2
components/ThemeScript/index.ts
Normal file
2
components/ThemeScript/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./ThemeScript";
|
||||
export { default } from "./ThemeScript";
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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;
|
||||
|
Reference in New Issue
Block a user