Migrate to app router (#2254)

This commit is contained in:
2025-02-07 11:33:38 -05:00
committed by GitHub
parent e97613dda5
commit 8aabb4a66f
179 changed files with 4095 additions and 4951 deletions
@@ -0,0 +1,6 @@
.blockquote {
margin-left: 0;
padding-left: 1.25em;
border-left: 0.25em solid var(--colors-link);
color: var(--colors-mediumDark);
}
+7 -7
View File
@@ -1,10 +1,10 @@
import { styled, theme } from "../../lib/styles/stitches.config";
import clsx from "clsx";
import type { ComponentPropsWithoutRef } from "react";
const Blockquote = styled("blockquote", {
marginLeft: 0,
paddingLeft: "1.25em",
borderLeft: `0.25em solid ${theme.colors.link}`,
color: theme.colors.mediumDark,
});
import styles from "./Blockquote.module.css";
const Blockquote = ({ className, ...rest }: ComponentPropsWithoutRef<"blockquote">) => (
<blockquote className={clsx(styles.blockquote, className)} {...rest} />
);
export default Blockquote;
-27
View File
@@ -1,27 +0,0 @@
import Turnstile from "react-turnstile";
import useHasMounted from "../../hooks/useHasMounted";
import useTheme from "../../hooks/useTheme";
import type { ComponentPropsWithoutRef } from "react";
export type CaptchaProps = Omit<ComponentPropsWithoutRef<typeof Turnstile>, "sitekey"> & {
className?: string;
};
const Captcha = ({ theme, className, ...rest }: CaptchaProps) => {
const hasMounted = useHasMounted();
const { activeTheme } = useTheme();
return (
<div className={className}>
{hasMounted && (
<Turnstile
sitekey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || "1x00000000000000000000AA"}
theme={theme || (activeTheme === "dark" ? activeTheme : "light")}
{...rest}
/>
)}
</div>
);
};
export default Captcha;
-2
View File
@@ -1,2 +0,0 @@
export * from "./Captcha";
export { default } from "./Captcha";
+37 -7
View File
@@ -1,10 +1,40 @@
import { styled, theme } from "../../lib/styles/stitches.config";
import CodeBlock from "../CodeBlock";
import CodeInline from "../CodeInline";
import type { PropsWithChildren } from "react";
const Code = styled("code", {
backgroundColor: theme.colors.codeBackground,
border: `1px solid ${theme.colors.kindaLight}`,
borderRadius: theme.radii.corner,
transition: `background ${theme.transitions.fade}, border ${theme.transitions.fade}`,
});
export type CodeProps = PropsWithChildren<{
forceBlock?: boolean;
className?: string;
}>;
// a simple wrapper component that "intelligently" picks between inline code and code blocks (w/ optional syntax
// highlighting & a clipboard button)
const Code = ({ forceBlock, className, children, ...rest }: CodeProps) => {
// detect if this input has already been touched by prism.js via rehype
const classNames = className?.split(" ");
const prismEnabled = classNames?.includes("code-highlight");
if (prismEnabled || forceBlock) {
// full multi-line code blocks with copy-to-clipboard button
// automatic if highlighted by prism, otherwise can be forced (rather than inlined) with `forceBlock={true}`
return (
<CodeBlock
highlight={prismEnabled && !classNames?.includes("language-plaintext")}
withCopyButton
className={className}
{...rest}
>
{children}
</CodeBlock>
);
}
// simple inline code in paragraphs, headings, etc. (never highlighted)
return (
<CodeInline className={className} {...rest}>
{children}
</CodeInline>
);
};
export default Code;
+105
View File
@@ -0,0 +1,105 @@
.codeBlock {
position: relative;
width: 100%;
margin: 1em auto;
color: var(--colors-codeText);
}
.codeBlock .code {
display: block;
overflow-x: auto;
padding: 1em;
font-size: 0.9em;
tab-size: 2px;
background-color: var(--colors-codeBackground);
border: 1px solid var(--colors-kindaLight);
border-radius: var(--radii-corner);
transition:
background var(--transitions-fade),
border var(--transitions-fade);
}
.codeBlock :global(.line-number)::before {
display: inline-block;
width: 1.5em;
margin-right: 1.5em;
text-align: right;
color: var(--colors-codeComment);
content: attr(line);
font-variant-numeric: tabular-nums;
user-select: none;
}
.codeBlock :global(.code-line):first-of-type {
margin-right: 3em;
}
.codeBlock.highlight :global(.token.comment),
.codeBlock.highlight :global(.token.prolog),
.codeBlock.highlight :global(.token.cdata) {
color: var(--colors-codeComment);
}
.codeBlock.highlight :global(.token.delimiter),
.codeBlock.highlight :global(.token.boolean),
.codeBlock.highlight :global(.token.keyword),
.codeBlock.highlight :global(.token.selector),
.codeBlock.highlight :global(.token.important),
.codeBlock.highlight :global(.token.doctype),
.codeBlock.highlight :global(.token.atrule),
.codeBlock.highlight :global(.token.url) {
color: var(--colors-codeKeyword);
}
.codeBlock.highlight :global(.token.tag),
.codeBlock.highlight :global(.token.builtin),
.codeBlock.highlight :global(.token.regex) {
color: var(--colors-codeNamespace);
}
.codeBlock.highlight :global(.token.property),
.codeBlock.highlight :global(.token.constant),
.codeBlock.highlight :global(.token.variable),
.codeBlock.highlight :global(.token.attr-value),
.codeBlock.highlight :global(.token.class-name),
.codeBlock.highlight :global(.token.string),
.codeBlock.highlight :global(.token.char) {
color: var(--colors-codeVariable);
}
.codeBlock.highlight :global(.token.literal-property),
.codeBlock.highlight :global(.token.attr-name) {
color: var(--colors-codeAttribute);
}
.codeBlock.highlight :global(.token.function) {
color: var(--colors-codeLiteral);
}
.codeBlock.highlight :global(.token.tag .punctuation),
.codeBlock.highlight :global(.token.attr-value .punctuation) {
color: var(--colors-codePunctuation);
}
.codeBlock.highlight :global(.token.inserted) {
color: var(--colors-codeAddition);
}
.codeBlock.highlight :global(.token.deleted) {
color: var(--colors-codeDeletion);
}
.codeBlock.highlight :global(.token.url) {
text-decoration: underline;
}
.codeBlock.highlight :global(.token.bold) {
font-weight: bold;
}
.codeBlock.highlight :global(.token.italic) {
font-style: italic;
}
.cornerCopyButton {
position: absolute;
top: 0;
right: 0;
padding: 0.65em;
background-color: var(--colors-backgroundInner);
border: 1px solid var(--colors-kindaLight);
border-top-right-radius: var(--radii-corner);
border-bottom-left-radius: var(--radii-corner);
transition:
background var(--transitions-fade),
border var(--transitions-fade);
}
+8 -91
View File
@@ -1,105 +1,22 @@
import Code from "../Code";
import clsx from "clsx";
import CopyButton from "../CopyButton";
import { styled, theme } from "../../lib/styles/stitches.config";
import type { ComponentPropsWithoutRef } from "react";
const Block = styled("div", {
position: "relative",
width: "100%",
margin: "1em auto",
color: theme.colors.codeText,
import styles from "./CodeBlock.module.css";
[`& ${Code}`]: {
display: "block",
overflowX: "auto",
padding: "1em",
fontSize: "0.9em",
tabSize: 2,
// optional line numbers added at time of prism compilation
".line-number::before": {
display: "inline-block",
width: "1.5em",
marginRight: "1.5em",
textAlign: "right",
color: theme.colors.codeComment,
content: "attr(line)", // added as spans by prism
fontVariantNumeric: "tabular-nums",
userSelect: "none",
},
// leave room for clipboard button to the right of the first line
".code-line:first-of-type": {
marginRight: "3em",
},
},
variants: {
highlight: {
true: {
// the following sub-classes MUST be global -- the prism rehype plugin isn't aware of this file
".token": {
"&.comment, &.prolog, &.cdata": {
color: theme.colors.codeComment,
},
"&.delimiter, &.boolean, &.keyword, &.selector, &.important, &.doctype, &.atrule, &.url": {
color: theme.colors.codeKeyword,
},
"&.tag, &.builtin, &.regex": {
color: theme.colors.codeNamespace,
},
"&.property, &.constant, &.variable, &.attr-value, &.class-name, &.string, &.char": {
color: theme.colors.codeVariable,
},
"&.literal-property, &.attr-name": {
color: theme.colors.codeAttribute,
},
"&.function": {
color: theme.colors.codeLiteral,
},
"&.tag .punctuation, &.attr-value .punctuation": {
color: theme.colors.codePunctuation,
},
"&.inserted": {
color: theme.colors.codeAddition,
},
"&.deleted": {
color: theme.colors.codeDeletion,
},
"&.url": { textDecoration: "underline" },
"&.bold": { fontWeight: "bold" },
"&.italic": { fontStyle: "italic" },
},
},
},
},
});
const CornerCopyButton = styled(CopyButton, {
position: "absolute",
top: 0,
right: 0,
padding: "0.65em",
backgroundColor: theme.colors.backgroundInner,
border: `1px solid ${theme.colors.kindaLight}`,
borderTopRightRadius: theme.radii.corner,
borderBottomLeftRadius: theme.radii.corner,
transition: `background ${theme.transitions.fade}, border ${theme.transitions.fade}`,
});
export type CodeBlockProps = ComponentPropsWithoutRef<typeof Code> & {
export type CodeBlockProps = ComponentPropsWithoutRef<"div"> & {
highlight?: boolean;
withCopyButton?: boolean;
};
const CodeBlock = ({ highlight, withCopyButton, className, children, ...rest }: CodeBlockProps) => {
return (
<Block highlight={highlight}>
{withCopyButton && <CornerCopyButton source={children} />}
<Code className={className?.replace("code-highlight", "").trim()} {...rest}>
<div className={clsx(styles.codeBlock, highlight && styles.highlight)}>
{withCopyButton && <CopyButton className={styles.cornerCopyButton} source={children} />}
<code className={clsx(styles.code, className)} {...rest}>
{children}
</Code>
</Block>
</code>
</div>
);
};
-40
View File
@@ -1,40 +0,0 @@
import CodeBlock from "../CodeBlock";
import CodeInline from "../CodeInline";
import type { PropsWithChildren } from "react";
export type CodeHybridProps = PropsWithChildren<{
forceBlock?: boolean;
className?: string;
}>;
// a simple wrapper component that "intelligently" picks between inline code and code blocks (w/ optional syntax
// highlighting & a clipboard button)
const CodeHybrid = ({ forceBlock, className, children, ...rest }: CodeHybridProps) => {
// detect if this input has already been touched by prism.js via rehype
const classNames = className?.split(" ");
const prismEnabled = classNames?.includes("code-highlight");
if (prismEnabled || forceBlock) {
// full multi-line code blocks with copy-to-clipboard button
// automatic if highlighted by prism, otherwise can be forced (rather than inlined) with `forceBlock={true}`
return (
<CodeBlock
highlight={prismEnabled && !classNames?.includes("language-plaintext")}
withCopyButton
className={className}
{...rest}
>
{children}
</CodeBlock>
);
}
// simple inline code in paragraphs, headings, etc. (never highlighted)
return (
<CodeInline className={className} {...rest}>
{children}
</CodeInline>
);
};
export default CodeHybrid;
-2
View File
@@ -1,2 +0,0 @@
export * from "./CodeHybrid";
export { default } from "./CodeHybrid";
@@ -0,0 +1,11 @@
.codeInline {
padding: 0.175em 0.3em;
font-size: 0.925em;
page-break-inside: avoid;
background-color: var(--colors-codeBackground);
border: 1px solid var(--colors-kindaLight);
border-radius: var(--radii-corner);
transition:
background var(--transitions-fade),
border var(--transitions-fade);
}
+7 -7
View File
@@ -1,10 +1,10 @@
import Code from "../Code";
import { styled } from "../../lib/styles/stitches.config";
import clsx from "clsx";
import type { ComponentPropsWithoutRef } from "react";
const CodeInline = styled(Code, {
padding: "0.175em 0.3em",
fontSize: "0.925em",
pageBreakInside: "avoid",
});
import styles from "./CodeInline.module.css";
const CodeInline = ({ className, ...rest }: ComponentPropsWithoutRef<"code">) => (
<code className={clsx(styles.codeInline, className)} {...rest} />
);
export default CodeInline;
@@ -0,0 +1,3 @@
.wrapper {
width: 100%;
}
+14 -24
View File
@@ -1,11 +1,7 @@
import clsx from "clsx";
import IFrame from "../IFrame";
import useHasMounted from "../../hooks/useHasMounted";
import useTheme from "../../hooks/useTheme";
import { styled } from "../../lib/styles/stitches.config";
const Wrapper = styled("div", {
width: "100%",
});
import styles from "./CodePenEmbed.module.css";
export type CodePenEmbedProps = {
username: string;
@@ -26,25 +22,19 @@ const CodePenEmbed = ({
editable = false,
className,
}: CodePenEmbedProps) => {
const hasMounted = useHasMounted();
const { activeTheme } = useTheme();
return (
<Wrapper className={className} css={{ height }}>
{hasMounted && (
<IFrame
src={`https://codepen.io/${username}/embed/${id}/?${new URLSearchParams({
"theme-id": activeTheme === "dark" ? activeTheme : "light",
"default-tab": `${defaultTab},result`,
preview: `${!!preview}`,
editable: `${!!editable}`,
})}`}
height={height}
allowScripts
noScroll
/>
)}
</Wrapper>
<div className={clsx(styles.wrapper, className)} style={{ height }}>
<IFrame
src={`https://codepen.io/${username}/embed/${id}/?${new URLSearchParams({
"default-tab": `${defaultTab},result`,
preview: `${!!preview}`,
editable: `${!!editable}`,
})}`}
height={height}
allowScripts
noScroll
/>
</div>
);
};
+6
View File
@@ -0,0 +1,6 @@
.comments {
margin-top: 2em;
padding-top: 2em;
border-top: 2px solid var(--colors-light);
min-height: 360px;
}
+9 -12
View File
@@ -1,22 +1,19 @@
"use client";
import Giscus from "@giscus/react";
import clsx from "clsx";
import useTheme from "../../hooks/useTheme";
import { styled, theme } from "../../lib/styles/stitches.config";
import config from "../../lib/config";
import type { ComponentPropsWithoutRef } from "react";
import type { GiscusProps } from "@giscus/react";
const Wrapper = styled("div", {
marginTop: "2em",
paddingTop: "2em",
borderTop: `2px solid ${theme.colors.light}`,
minHeight: "360px",
});
import styles from "./Comments.module.css";
export type CommentsProps = ComponentPropsWithoutRef<typeof Wrapper> & {
export type CommentsProps = ComponentPropsWithoutRef<"div"> & {
title: string;
};
const Comments = ({ title, ...rest }: CommentsProps) => {
const Comments = ({ title, className, ...rest }: CommentsProps) => {
const { activeTheme } = useTheme();
// fail silently if giscus isn't configured
@@ -27,7 +24,7 @@ const Comments = ({ title, ...rest }: CommentsProps) => {
// TODO: use custom `<Loading />` spinner component during suspense
return (
<Wrapper {...rest}>
<div className={clsx(styles.comments, className)} {...rest}>
<Giscus
repo={config.githubRepo as GiscusProps["repo"]}
repoId={config.giscusConfig.repoId}
@@ -38,10 +35,10 @@ const Comments = ({ title, ...rest }: CommentsProps) => {
reactionsEnabled="1"
emitMetadata="0"
inputPosition="top"
loading="eager" // still lazily loaded with react-intersection-observer
loading="lazy"
theme={activeTheme === "dark" ? activeTheme : "light"}
/>
</Wrapper>
</div>
);
};
-302
View File
@@ -1,302 +0,0 @@
import { useState } from "react";
import { Formik, Form, Field } from "formik";
import TextareaAutosize from "react-textarea-autosize";
import Link from "../Link";
import Captcha from "../Captcha";
import { GoCheck, GoX } from "react-icons/go";
import { SiMarkdown } from "react-icons/si";
import { styled, theme, css } from "../../lib/styles/stitches.config";
import type { FormikHelpers, FormikProps, FieldInputProps, FieldMetaProps } from "formik";
// CSS applied to both `<input />` and `<textarea />`
const InputStyles = css({
width: "100%",
padding: "0.8em",
margin: "0.6em 0",
border: `2px solid ${theme.colors.light}`,
borderRadius: theme.radii.corner,
color: theme.colors.text,
backgroundColor: theme.colors.superDuperLight,
transition: `background ${theme.transitions.fade}`,
"&:focus": {
outline: "none",
borderColor: theme.colors.link,
},
variants: {
missing: {
true: {
borderColor: theme.colors.error,
},
false: {},
},
},
});
const Input = styled("input", InputStyles);
const TextArea = styled(TextareaAutosize, InputStyles, {
marginBottom: 0,
lineHeight: 1.5,
minHeight: "10em",
resize: "vertical",
});
const MarkdownTip = styled("div", {
fontSize: "0.825em",
lineHeight: 1.75,
});
const MarkdownTipIcon = styled(SiMarkdown, {
display: "inline",
width: "1.25em",
height: "1.25em",
verticalAlign: "-0.25em",
marginRight: "0.25em",
});
const Turnstile = styled(Captcha, {
margin: "1em 0",
});
const ActionRow = styled("div", {
display: "flex",
alignItems: "center",
minHeight: "3.75em",
});
const SubmitButton = styled("button", {
flexShrink: 0,
height: "3.25em",
padding: "1em 1.25em",
marginRight: "1.5em",
border: 0,
borderRadius: theme.radii.corner,
cursor: "pointer",
userSelect: "none",
fontWeight: 500,
color: theme.colors.text,
backgroundColor: theme.colors.kindaLight,
"&:hover, &:focus-visible": {
color: theme.colors.superDuperLight,
backgroundColor: theme.colors.link,
},
variants: {
hidden: {
true: {
display: "none",
},
false: {},
},
},
});
const SubmitIcon = styled("span", {
fontSize: "1.3em",
marginRight: "0.3em",
lineHeight: 1,
});
const Result = styled("div", {
fontWeight: 600,
lineHeight: 1.5,
variants: {
status: {
success: {
color: theme.colors.success,
},
error: {
color: theme.colors.error,
},
},
hidden: {
true: {
display: "none",
},
false: {},
},
},
});
const ResultIcon = styled("svg", {
display: "inline",
width: "1.3em",
height: "1.3em",
verticalAlign: "-0.3em",
fill: "currentColor",
});
type FormValues = {
name: string;
email: string;
message: string;
"cf-turnstile-response": string;
};
export type ContactFormProps = {
className?: string;
};
const ContactForm = ({ className }: ContactFormProps) => {
// status/feedback:
const [submitted, setSubmitted] = useState(false);
const [success, setSuccess] = useState(false);
const [feedback, setFeedback] = useState("");
const handleSubmit = (values: FormValues, { setSubmitting }: FormikHelpers<FormValues>) => {
// once a user attempts a submission, this is true and stays true whether or not the next attempt(s) are successful
setSubmitted(true);
// 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(values),
})
.then((response) => response.json())
.then((data) => {
if (data.success === true) {
// handle successful submission
// disable submissions, hide the send button, and let user know we were successful
setSuccess(true);
setFeedback("Thanks! You should hear from me soon.");
} else {
// pass on any error sent by the server to the catch block below
throw new Error(data.message);
}
})
.catch((error) => {
setSuccess(false);
if (error.message === "missing_data") {
// this should be validated client-side but it's also checked server-side just in case someone slipped past
setFeedback("Please make sure that all fields are properly filled in.");
} else if (error.message === "invalid_captcha") {
// missing/invalid captcha
setFeedback("Did you complete the CAPTCHA? (If you're human, that is...)");
} else {
// something else went wrong, and it's probably my fault...
setFeedback("Internal server error... Try again later or shoot me an old-fashioned email?");
}
})
.finally(() => setSubmitting(false));
};
return (
<Formik
onSubmit={handleSubmit}
initialValues={{
name: "",
email: "",
message: "",
"cf-turnstile-response": "",
}}
validate={(values: FormValues) => {
const errors: Partial<Record<keyof FormValues, boolean>> = {};
errors.name = !values.name;
errors.email = !values.email; // also loosely validated that it's email-like via browser (not foolproof)
errors.message = !values.message;
errors["cf-turnstile-response"] = !values["cf-turnstile-response"];
if (!errors.name && !errors.email && !errors.message && !errors["cf-turnstile-response"]) {
setFeedback("");
return {};
} else {
setSuccess(false);
setFeedback("Please make sure that all fields are properly filled in.");
}
return errors;
}}
>
{({ setFieldValue, isSubmitting }: FormikProps<FormValues>) => (
<Form className={className} name="contact">
<Field name="name">
{({ field, meta }: { field: FieldInputProps<string>; meta: FieldMetaProps<string> }) => (
<Input
type="text"
placeholder="Name"
disabled={success}
missing={!!(meta.error && meta.touched)}
{...field}
/>
)}
</Field>
<Field name="email">
{({ field, meta }: { field: FieldInputProps<string>; meta: FieldMetaProps<string> }) => (
<Input
type="email"
inputMode="email"
placeholder="Email"
disabled={success}
missing={!!(meta.error && meta.touched)}
{...field}
/>
)}
</Field>
<Field name="message">
{({ field, meta }: { field: FieldInputProps<string>; meta: FieldMetaProps<string> }) => (
<TextArea
placeholder="Write something..."
minRows={5}
disabled={success}
missing={!!(meta.error && meta.touched)}
{...field}
/>
)}
</Field>
<MarkdownTip>
<MarkdownTipIcon /> Basic{" "}
<Link href="https://commonmark.org/help/" title="Markdown reference sheet" css={{ fontWeight: 600 }}>
Markdown syntax
</Link>{" "}
is allowed here, e.g.: <strong>**bold**</strong>, <em>_italics_</em>, [
<Link href="https://jarv.is" underline={false} openInNewTab>
links
</Link>
](https://jarv.is), and <code>`code`</code>.
</MarkdownTip>
<Turnstile onVerify={(token) => setFieldValue("cf-turnstile-response", token)} />
<ActionRow>
<SubmitButton
type="submit"
title="Send Message"
aria-label="Send Message"
onClick={() => setSubmitted(true)}
disabled={isSubmitting}
hidden={success}
>
{isSubmitting ? (
<span>Sending...</span>
) : (
<>
<SubmitIcon>📤</SubmitIcon> <span>Send</span>
</>
)}
</SubmitButton>
<Result status={success ? "success" : "error"} hidden={!submitted || !feedback || isSubmitting}>
<ResultIcon as={success ? GoCheck : GoX} /> {feedback}
</Result>
</ActionRow>
</Form>
)}
</Formik>
);
};
export default ContactForm;
-2
View File
@@ -1,2 +0,0 @@
export * from "./ContactForm";
export { default } from "./ContactForm";
+12
View File
@@ -0,0 +1,12 @@
.content {
font-size: 0.9em;
line-height: 1.7;
color: var(--colors-text);
}
@media (max-width: 768px) {
.content {
font-size: 0.925em;
line-height: 1.85;
}
}
+6 -10
View File
@@ -1,14 +1,10 @@
import { styled, theme } from "../../lib/styles/stitches.config";
import clsx from "clsx";
import type { ComponentPropsWithoutRef } from "react";
const Content = styled("div", {
fontSize: "0.9em",
lineHeight: 1.7,
color: theme.colors.text,
import styles from "./Content.module.css";
"@medium": {
fontSize: "0.925em",
lineHeight: 1.85,
},
});
const Content = ({ className, ...rest }: ComponentPropsWithoutRef<"div">) => (
<div className={clsx(styles.content, className)} {...rest} />
);
export default Content;
@@ -0,0 +1,19 @@
.button {
line-height: 1px;
cursor: pointer;
}
.button:hover,
.button:focus-visible {
color: var(--colors-link);
}
.button.copied {
color: var(--colors-success) !important;
}
.icon {
width: 1.25em;
height: 1.25em;
vertical-align: -0.3em;
}
+14 -33
View File
@@ -1,45 +1,26 @@
"use client";
import { forwardRef, useState, useEffect } from "react";
import innerText from "react-innertext";
import copy from "copy-to-clipboard";
import clsx from "clsx";
import { FiClipboard, FiCheck } from "react-icons/fi";
import { styled, theme } from "../../lib/styles/stitches.config";
import type { ReactNode, Ref, ComponentPropsWithoutRef, ElementRef, MouseEventHandler } from "react";
const Button = styled("button", {
lineHeight: 1,
cursor: "pointer",
import styles from "./CopyButton.module.css";
variants: {
copied: {
true: {
color: theme.colors.success,
},
false: {
color: theme.colors.mediumDark,
"&:hover, &:focus-visible": {
color: theme.colors.link,
},
},
},
},
});
const Icon = styled("svg", {
width: "1.25em",
height: "1.25em",
verticalAlign: "-0.3em",
});
export type CopyButtonProps = ComponentPropsWithoutRef<typeof Button> & {
export type CopyButtonProps = ComponentPropsWithoutRef<"button"> & {
source: string | ReactNode;
timeout?: number;
};
const CopyButton = ({ source, timeout = 2000, ...rest }: CopyButtonProps, ref: Ref<ElementRef<typeof Button>>) => {
const CopyButton = (
{ source, timeout = 2000, className, ...rest }: CopyButtonProps,
ref: Ref<ElementRef<"button">>
) => {
const [copied, setCopied] = useState(false);
const handleCopy: MouseEventHandler<ElementRef<typeof Button>> = (e) => {
const handleCopy: MouseEventHandler<ElementRef<"button">> = (e) => {
// prevent unintentional double-clicks by unfocusing button
e.currentTarget.blur();
@@ -67,17 +48,17 @@ const CopyButton = ({ source, timeout = 2000, ...rest }: CopyButtonProps, ref: R
}, [timeout, copied]);
return (
<Button
<button
ref={ref}
title="Copy to clipboard"
aria-label="Copy to clipboard"
onClick={handleCopy}
disabled={copied}
copied={copied}
className={clsx(styles.button, copied && styles.copied, className)}
{...rest}
>
<Icon as={copied ? FiCheck : FiClipboard} />
</Button>
{copied ? <FiCheck className={styles.icon} /> : <FiClipboard className={styles.icon} />}
</button>
);
};
+11
View File
@@ -0,0 +1,11 @@
.figure {
margin: 1em auto;
text-align: center;
}
.caption {
font-size: 0.9em;
line-height: 1.5;
color: var(--colors-medium);
margin-top: -0.4em;
}
+5 -15
View File
@@ -1,19 +1,9 @@
import innerText from "react-innertext";
import clsx from "clsx";
import Image from "../Image";
import { styled, theme } from "../../lib/styles/stitches.config";
import type { PropsWithChildren, ComponentPropsWithoutRef } from "react";
const Wrapper = styled("figure", {
margin: "1em auto",
textAlign: "center",
});
const Caption = styled("figcaption", {
fontSize: "0.9em",
lineHeight: 1.5,
color: theme.colors.medium,
marginTop: "-0.4em",
});
import styles from "./Figure.module.css";
export type FigureProps = Omit<ComponentPropsWithoutRef<typeof Image>, "alt"> &
PropsWithChildren<{
@@ -22,10 +12,10 @@ export type FigureProps = Omit<ComponentPropsWithoutRef<typeof Image>, "alt"> &
const Figure = ({ children, alt, className, ...imageProps }: FigureProps) => {
return (
<Wrapper className={className}>
<figure className={clsx(styles.figure, className)}>
<Image alt={alt || innerText(children)} {...imageProps} />
<Caption>{children}</Caption>
</Wrapper>
<figcaption className={styles.caption}>{children}</figcaption>
</figure>
);
};
+92
View File
@@ -0,0 +1,92 @@
.footer {
width: 100%;
padding: 1.25em 1.5em;
border-top: 1px solid var(--colors-kindaLight);
background-color: var(--colors-backgroundOuter);
color: var(--colors-mediumDark);
transition:
background var(--transitions-fade),
border var(--transitions-fade);
}
.row {
display: flex;
width: 100%;
max-width: var(--sizes-maxLayoutWidth);
margin: 0 auto;
justify-content: space-between;
font-size: 0.8em;
line-height: 2.3;
}
.link {
color: var(--colors-mediumDark) !important;
}
.link.hover:hover,
.link.hover:focus-visible {
color: var(--colors-medium) !important;
}
.link.underline {
padding-bottom: 2px;
border-bottom: 1px solid var(--colors-light);
}
.link.underline:hover,
.link.hover:focus-visible {
border-bottom-color: var(--colors-kindaLight);
}
.icon {
display: inline;
width: 1.25em;
height: 1.25em;
vertical-align: -0.25em;
margin: 0 0.075em;
}
.heart {
display: inline-block;
color: var(--colors-error);
}
@media (prefers-reduced-motion: no-preference) {
.heart {
animation: pulse 10s ease 7.5s infinite;
will-change: transform;
}
@keyframes pulse {
0% {
transform: scale(1);
}
2% {
transform: scale(1.25);
}
4% {
transform: scale(1);
}
6% {
transform: scale(1.2);
}
8% {
transform: scale(1);
}
/* pause for ~9 out of 10 seconds */
100% {
transform: scale(1);
}
}
}
@media (max-width: 768px) {
.footer {
padding: 1em 1.25em;
}
/* stack columns on left instead of flexboxing across */
.row {
display: block;
}
}
+22 -89
View File
@@ -1,124 +1,57 @@
import clsx from "clsx";
import Link from "../Link";
import { GoHeartFill } from "react-icons/go";
import { SiNextdotjs } from "react-icons/si";
import { styled, theme, keyframes } from "../../lib/styles/stitches.config";
import config from "../../lib/config";
import type { ComponentPropsWithoutRef } from "react";
const Wrapper = styled("footer", {
width: "100%",
padding: "1.25em 1.5em",
borderTop: `1px solid ${theme.colors.kindaLight}`,
backgroundColor: theme.colors.backgroundOuter,
color: theme.colors.mediumDark,
transition: `background ${theme.transitions.fade}, border ${theme.transitions.fade}`,
import styles from "./Footer.module.css";
"@medium": {
padding: "1em 1.25em",
},
});
export type FooterProps = ComponentPropsWithoutRef<"footer">;
const Row = styled("div", {
display: "flex",
width: "100%",
maxWidth: theme.sizes.maxLayoutWidth,
margin: "0 auto",
justifyContent: "space-between",
fontSize: "0.8em",
lineHeight: 2.3,
// stack columns on left instead of flexboxing across
"@medium": {
display: "block",
},
});
const PlainLink = styled(Link, {
color: theme.colors.mediumDark,
});
const Icon = styled("svg", {
display: "inline",
width: "1.25em",
height: "1.25em",
verticalAlign: "-0.25em",
margin: "0 0.075em",
});
const Heart = styled("span", {
display: "inline-block",
color: theme.colors.error, // somewhat ironically color the heart with the themed "error" red... </3
"@media (prefers-reduced-motion: no-preference)": {
animation: `${keyframes({
"0%": { transform: "scale(1)" },
"2%": { transform: "scale(1.25)" },
"4%": { transform: "scale(1)" },
"6%": { transform: "scale(1.2)" },
"8%": { transform: "scale(1)" },
// pause for ~9 out of 10 seconds
"100%": { transform: "scale(1)" },
})} 10s ease 7.5s infinite`,
willChange: "transform",
},
});
export type FooterProps = ComponentPropsWithoutRef<typeof Wrapper>;
const Footer = ({ ...rest }: FooterProps) => {
const Footer = ({ className, ...rest }: FooterProps) => {
return (
<Wrapper {...rest}>
<Row>
<footer className={clsx(styles.footer, className)} {...rest}>
<div className={styles.row}>
<div>
Content{" "}
<PlainLink href="/license/" title={config.license} underline={false}>
<Link href="/license" title={config.license} underline={false} className={styles.link}>
licensed under {config.licenseAbbr}
</PlainLink>
</Link>
,{" "}
<PlainLink href="/previously/" title="Previously on..." underline={false}>
<Link href="/previously" title="Previously on..." underline={false} className={styles.link}>
{config.copyrightYearStart}
</PlainLink>{" "}
</Link>{" "}
{new Date(process.env.RELEASE_DATE || Date.now()).getUTCFullYear()}.
</div>
<div>
Made with{" "}
<Heart title="Love">
<Icon as={GoHeartFill} css={{ strokeWidth: 2 }} />
</Heart>{" "}
<span className={styles.heart} title="Love">
<GoHeartFill className={styles.icon} style={{ strokeWidth: 2 }} />
</span>{" "}
and{" "}
<PlainLink
<Link
href="https://nextjs.org/"
title="Powered by Next.js"
aria-label="Next.js"
underline={false}
css={{
"&:hover, &:focus-visible": {
color: theme.colors.medium,
},
}}
className={clsx(styles.link, styles.hover)}
>
<Icon as={SiNextdotjs} />
</PlainLink>
<SiNextdotjs className={styles.icon} />
</Link>
.{" "}
<PlainLink
<Link
href={`https://github.com/${config.githubRepo}`}
title="View Source on GitHub"
underline={false}
css={{
paddingBottom: "2px",
borderBottom: `1px solid ${theme.colors.light}`,
"&:hover, &:focus-visible": {
borderColor: theme.colors.kindaLight,
},
}}
className={clsx(styles.link, styles.underline)}
>
View source.
</PlainLink>
</Link>
</div>
</Row>
</Wrapper>
</div>
</footer>
);
};
+2
View File
@@ -1,3 +1,5 @@
"use client";
import Frame from "react-frame-component";
import useHasMounted from "../../hooks/useHasMounted";
+82
View File
@@ -0,0 +1,82 @@
.header {
width: 100%;
height: 4.5em;
padding: 0.7em 1.5em;
border-bottom: 1px solid var(--colors-kindaLight);
background-color: var(--colors-backgroundHeader);
transition:
background var(--transitions-fade),
border var(--transitions-fade);
z-index: 9999px;
/* blurry glass-like background effect (except on firefox...?) */
backdrop-filter: saturate(180%) blur(5px);
}
.selfieImage {
width: 50px;
height: 50px;
border: 1px solid var(--colors-light);
border-radius: 50%;
}
.selfieLink {
display: inline-flex;
flex-shrink: 0;
align-items: center;
color: var(--colors-mediumDark) !important;
}
.selfieLink:hover,
.selfieLink:focus-visible {
color: var(--colors-link) !important;
}
.name {
margin: 0 0.6em;
font-size: 1.15em;
font-weight: 500;
letter-spacing: 0.02em;
line-height: 1;
}
.nav {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
max-width: var(--sizes-maxLayoutWidth);
margin: 0 auto;
}
@media (max-width: 768px) {
.header {
padding: 0.75em 1.25em;
height: 5.9em;
}
.selfieImage {
width: 70px;
height: 70px;
border-width: 2px;
}
.selfieLink:hover .selfieImage,
.selfieLink:focus-visible .selfieImage {
border-color: var(--colors-linkUnderline);
}
.name {
display: none;
}
.menu {
max-width: 325px;
}
}
@media (max-width: 380px) {
.menu {
max-width: 225px;
}
}
+28 -45
View File
@@ -1,55 +1,38 @@
import Selfie from "../Selfie";
import clsx from "clsx";
import Link from "../Link";
import Image from "../Image";
import Menu from "../Menu";
import { styled, theme } from "../../lib/styles/stitches.config";
import config from "../../lib/config";
import type { ComponentPropsWithoutRef } from "react";
const Wrapper = styled("header", {
width: "100%",
height: "4.5em",
padding: "0.7em 1.5em",
borderBottom: `1px solid ${theme.colors.kindaLight}`,
backgroundColor: theme.colors.backgroundHeader,
transition: `background ${theme.transitions.fade}, border ${theme.transitions.fade}`,
zIndex: 9999,
import styles from "./Header.module.css";
// blurry glass-like background effect (except on firefox...?)
backdropFilter: "saturate(180%) blur(5px)",
import selfieJpg from "../../public/static/images/selfie.jpg";
"@medium": {
padding: "0.75em 1.25em",
height: "5.9em",
},
});
export type HeaderProps = ComponentPropsWithoutRef<"header">;
const Nav = styled("nav", {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
width: "100%",
maxWidth: theme.sizes.maxLayoutWidth,
margin: "0 auto",
});
const ResponsiveMenu = styled(Menu, {
"@medium": {
maxWidth: "325px",
},
"@small": {
maxWidth: "225px",
},
});
export type HeaderProps = ComponentPropsWithoutRef<typeof Wrapper>;
const Header = ({ ...rest }: HeaderProps) => {
const Header = ({ className, ...rest }: HeaderProps) => {
return (
<Wrapper {...rest}>
<Nav>
<Selfie />
<ResponsiveMenu />
</Nav>
</Wrapper>
<header className={clsx(styles.header, className)} {...rest}>
<nav className={styles.nav}>
<Link href="/" rel="author" title={config.authorName} underline={false} className={styles.selfieLink}>
<Image
src={selfieJpg}
alt={`Photo of ${config.authorName}`}
className={styles.selfieImage}
width={70}
height={70}
quality={60}
placeholder="empty"
inline
priority
/>
<span className={styles.name}>{config.authorName}</span>
</Link>
<Menu className={styles.menu} />
</nav>
</header>
);
};
+48
View File
@@ -0,0 +1,48 @@
.h {
margin-top: 1em;
margin-bottom: 0.5em;
line-height: 1.5;
/* offset (approximately) with sticky header so jumped-to content isn't hiding behind it.
note: use rem so it isn't based on the heading's font size. */
scroll-margin-top: 5.5rem;
}
.h.divider {
padding-bottom: 0.25em;
border-bottom: 1px solid var(--colors-kindaLight);
}
.anchor {
margin: 0 0.4em;
padding: 0 0.2em;
color: var(--colors-medium) !important;
/* overridden on hover below (except on small screens) */
opacity: 0;
}
/* show anchor link when hovering anywhere over the heading line, or on keyboard tab focus */
.anchor:hover,
.anchor:focus-visible {
color: var(--colors-link) !important;
}
.h:hover .anchor,
.h:focus-visible .anchor {
opacity: 1;
}
@media (max-width: 768px) {
.h {
scroll-margin-top: 5.5rem;
}
.anchor {
margin: 0 0.2em;
padding: 0 0.4em;
/* don't require hover to show anchor link on small (likely touch) screens */
opacity: 1;
}
}
+12 -55
View File
@@ -1,70 +1,27 @@
import innerText from "react-innertext";
import clsx from "clsx";
import HeadingAnchor from "../HeadingAnchor";
import { styled, theme } from "../../lib/styles/stitches.config";
import type { ComponentPropsWithoutRef } from "react";
import type { JSX, ComponentPropsWithoutRef } from "react";
const Anchor = styled(HeadingAnchor, {
margin: "0 0.4em",
padding: "0 0.2em",
color: theme.colors.medium,
opacity: 0, // overridden on hover below (except on small screens)
import styles from "./Heading.module.css";
"&:hover, &:focus-visible": {
color: theme.colors.link,
},
"@medium": {
margin: "0 0.2em",
padding: "0 0.4em",
// don't require hover to show anchor link on small (likely touch) screens
opacity: 1,
},
});
const H = styled("h1", {
marginTop: "1em",
marginBottom: "0.5em",
lineHeight: 1.5,
// offset (approximately) with sticky header so jumped-to content isn't hiding behind it.
// note: use rem so it isn't based on the heading's font size.
scrollMarginTop: "5.5rem",
"@medium": {
scrollMarginTop: "6.5rem",
},
// show anchor link when hovering anywhere over the heading line, or on keyboard tab focus
[`&:hover ${Anchor}, ${Anchor}:focus-visible`]: {
opacity: 1,
},
variants: {
// subtle horizontal rule under the heading, set by default on `<h2>`s
divider: {
true: {
paddingBottom: "0.25em",
borderBottom: `1px solid ${theme.colors.kindaLight}`,
},
false: {},
},
},
});
export type HeadingProps = ComponentPropsWithoutRef<typeof H> & {
export type HeadingProps = ComponentPropsWithoutRef<"h1"> & {
level: 1 | 2 | 3 | 4 | 5 | 6;
divider?: boolean;
};
const Heading = ({ level, id, divider, children, ...rest }: HeadingProps) => {
const Heading = ({ level, id, divider, className, children, ...rest }: HeadingProps) => {
const HWild: keyof JSX.IntrinsicElements = `h${level}`;
return (
<H as={`h${level}` as keyof JSX.IntrinsicElements} id={id} divider={divider || level === 2} {...rest}>
<HWild id={id} className={clsx(styles.h, (divider || level === 2) && styles.divider, className)} {...rest}>
{children}
{/* add anchor link to H2s and H3s. ID is either provided or automatically generated by rehype-slug. */}
{id && (level === 2 || level === 3) && <Anchor id={id} title={innerText(children)} />}
</H>
{id && (level === 2 || level === 3) && (
<HeadingAnchor id={id} title={innerText(children)} className={styles.anchor} />
)}
</HWild>
);
};
+8 -1
View File
@@ -9,7 +9,14 @@ export type HeadingAnchorProps = Omit<ComponentPropsWithoutRef<typeof Link>, "hr
const HeadingAnchor = ({ id, title, ...rest }: HeadingAnchorProps) => {
return (
<Link href={`#${id}`} title={`Jump to "${title}"`} aria-hidden underline={false} css={{ lineHeight: 1 }} {...rest}>
<Link
href={`#${id}`}
title={`Jump to "${title}"`}
aria-hidden
underline={false}
style={{ lineHeight: 1 }}
{...rest}
>
<FiLink size="0.8em" />
</Link>
);
+2
View File
@@ -1,3 +1,5 @@
"use client";
import useSWRImmutable from "swr/immutable";
import { useErrorBoundary } from "react-error-boundary";
import commaNumber from "comma-number";
@@ -0,0 +1,6 @@
.hr {
margin: 1.5em auto;
height: 0.175em;
border: 0;
background-color: var(--colors-light);
}
+7 -7
View File
@@ -1,10 +1,10 @@
import { styled, theme } from "../../lib/styles/stitches.config";
import clsx from "clsx";
import type { ComponentPropsWithoutRef } from "react";
const HorizontalRule = styled("hr", {
margin: "1.5em auto",
height: "0.175em",
border: 0,
backgroundColor: theme.colors.light,
});
import styles from "./HorizontalRule.module.css";
const HorizontalRule = ({ className, ...rest }: ComponentPropsWithoutRef<"hr">) => (
<hr className={clsx(styles.hr, className)} {...rest} />
);
export default HorizontalRule;
+7
View File
@@ -0,0 +1,7 @@
.iframe {
width: 100%;
display: block;
margin: 1em auto;
border: 2px solid var(--colors-kindaLight);
border-radius: var(--radii-corner);
}
+8 -13
View File
@@ -1,15 +1,9 @@
import { styled, theme } from "../../lib/styles/stitches.config";
import clsx from "clsx";
import type { ComponentPropsWithoutRef } from "react";
const RoundedIFrame = styled("iframe", {
width: "100%",
display: "block",
margin: "1em auto",
border: `2px solid ${theme.colors.kindaLight}`,
borderRadius: theme.radii.corner,
});
import styles from "./IFrame.module.css";
export type IFrameProps = ComponentPropsWithoutRef<typeof RoundedIFrame> & {
export type IFrameProps = ComponentPropsWithoutRef<"iframe"> & {
src: string;
height: number;
width?: number; // defaults to 100%
@@ -17,18 +11,19 @@ export type IFrameProps = ComponentPropsWithoutRef<typeof RoundedIFrame> & {
noScroll?: boolean;
};
const IFrame = ({ src, title, height, width, allowScripts, noScroll, css, ...rest }: IFrameProps) => {
const IFrame = ({ src, title, height, width, allowScripts, noScroll, className, style, ...rest }: IFrameProps) => {
return (
<RoundedIFrame
<iframe
src={src}
title={title}
sandbox={allowScripts ? "allow-same-origin allow-scripts allow-popups" : undefined}
scrolling={noScroll ? "no" : undefined}
loading="lazy"
css={{
className={clsx(styles.iframe, className)}
style={{
height: `${height}px`,
maxWidth: width ? `${width}px` : "100%",
...css,
...style,
}}
{...rest}
/>
+14
View File
@@ -0,0 +1,14 @@
.image {
height: auto;
max-width: 100%;
border-radius: var(--radii-corner);
}
.block {
display: block;
line-height: 0;
/* default to centering all images */
margin: 1em auto;
text-align: center;
}
+23 -26
View File
@@ -1,33 +1,30 @@
import NextImage from "next/image";
import Link from "../Link";
import { styled, theme } from "../../lib/styles/stitches.config";
import clsx from "clsx";
import Link, { LinkProps } from "../Link";
import type { ComponentPropsWithoutRef } from "react";
import type { ImageProps as NextImageProps, StaticImageData } from "next/image";
import styles from "./Image.module.css";
const DEFAULT_QUALITY = 60;
const DEFAULT_WIDTH = Number.parseInt(theme.sizes.maxLayoutWidth.value, 10); // see lib/styles/stitches.config.ts
const DEFAULT_WIDTH = 865;
const Block = styled("div", {
display: "block",
lineHeight: 0,
export type ImageProps = ComponentPropsWithoutRef<typeof NextImage> &
Partial<Pick<LinkProps, "href">> & {
inline?: boolean; // don't wrap everything in a `<div>` block
};
// default to centering all images
margin: "1em auto",
textAlign: "center",
});
const StyledImage = styled(NextImage, {
height: "auto",
maxWidth: "100%",
borderRadius: theme.radii.corner,
});
export type ImageProps = ComponentPropsWithoutRef<typeof StyledImage> & {
href?: string; // optionally wrap image in a link
inline?: boolean; // don't wrap everything in a `<div>` block
};
const Image = ({ src, width, height, quality = DEFAULT_QUALITY, placeholder, href, inline, ...rest }: ImageProps) => {
const Image = ({
src,
width,
height,
quality = DEFAULT_QUALITY,
placeholder,
href,
inline,
className,
...rest
}: ImageProps) => {
const imageProps: NextImageProps = {
// strip "px" from dimensions: https://stackoverflow.com/a/4860249/1438024
width: typeof width === "string" ? Number.parseInt(width, 10) : width,
@@ -63,13 +60,13 @@ const Image = ({ src, width, height, quality = DEFAULT_QUALITY, placeholder, hre
const StyledImageWithProps = href ? (
<Link href={href} underline={false}>
<StyledImage {...imageProps} />
<NextImage className={clsx(styles.image, className)} {...imageProps} />
</Link>
) : (
<StyledImage {...imageProps} />
<NextImage className={clsx(styles.image, className)} {...imageProps} />
);
return inline ? StyledImageWithProps : <Block>{StyledImageWithProps}</Block>;
return inline ? StyledImageWithProps : <div className={styles.block}>{StyledImageWithProps}</div>;
};
export default Image;
+26
View File
@@ -0,0 +1,26 @@
.flex {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.default {
width: 100%;
padding: 1.5em;
}
.container {
max-width: var(--sizes-maxLayoutWidth);
margin: 0 auto;
display: block;
}
.stickyHeader {
position: sticky;
top: 0;
z-index: 1000;
}
.flexedFooter {
flex: 1;
}
+12 -48
View File
@@ -1,64 +1,28 @@
import clsx from "clsx";
import Header from "../Header";
import Footer from "../Footer";
import { SkipToContentLink, SkipToContentTarget } from "../SkipToContent";
import { styled, theme } from "../../lib/styles/stitches.config";
import type { ComponentPropsWithoutRef } from "react";
const Flex = styled("div", {
display: "flex",
flexDirection: "column",
minHeight: "100vh",
});
import styles from "./Layout.module.css";
const Default = styled("main", {
width: "100%",
padding: "1.5em",
});
export type LayoutProps = ComponentPropsWithoutRef<"div">;
const Container = styled("div", {
maxWidth: theme.sizes.maxLayoutWidth,
margin: "0 auto",
display: "block",
});
// stick header to the top of the page when scrolling
const StickyHeader = styled(Header, {
position: "sticky",
top: 0,
});
// footer needs to fill the remaining vertical screen space. doing it here to keep flex stuff together.
const FlexedFooter = styled(Footer, {
flex: 1,
});
export type LayoutProps = ComponentPropsWithoutRef<typeof Flex> & {
container?: boolean; // pass false to disable default `<main>` container styles with padding, etc.
};
const Layout = ({ container = true, children, ...rest }: LayoutProps) => {
const Layout = ({ className, children, ...rest }: LayoutProps) => {
return (
<>
<SkipToContentLink />
<Flex {...rest}>
<StickyHeader />
<div className={clsx(styles.flex, className)} {...rest}>
<Header className={styles.stickyHeader} />
{/* passing `container={false}` to Layout allows 100% control of the content area on a per-page basis */}
{container ? (
<Default>
<SkipToContentTarget />
<Container>{children}</Container>
</Default>
) : (
<>
<SkipToContentTarget />
{children}
</>
)}
<main className={styles.default}>
<SkipToContentTarget />
<div className={styles.container}>{children}</div>
</main>
<FlexedFooter />
</Flex>
<Footer className={styles.flexedFooter} />
</div>
</>
);
};
+23
View File
@@ -0,0 +1,23 @@
.link {
color: var(--colors-link);
text-decoration: none;
}
.link.underline {
background-image: linear-gradient(var(--colors-linkUnderline), var(--colors-linkUnderline));
background-position: 0% 100%;
background-repeat: no-repeat;
background-size: 0% 2px;
padding-bottom: 3px;
}
.link.underline:hover,
.link.underline:focus-visible {
background-size: 100% 2px;
}
@media (prefers-reduced-motion: no-preference) {
.link.underline {
transition: background-size var(--transitions-linkHover);
}
}
+23 -36
View File
@@ -1,44 +1,25 @@
import NextLink from "next/link";
import clsx from "clsx";
import objStr from "obj-str";
import { styled, theme, stitchesConfig } from "../../lib/styles/stitches.config";
import type { ComponentPropsWithoutRef } from "react";
const StyledLink = styled(NextLink, {
color: theme.colors.link,
textDecoration: "none",
import styles from "./Link.module.css";
variants: {
underline: {
// fancy animated link underline effect (on by default)
true: {
// sets psuedo linear-gradient() for the underline's color; see stitches config for the weird calculation behind
// the local `$$underlineColor` variable.
...stitchesConfig.utils.setUnderlineColor({ color: "$colors$linkUnderline" }),
backgroundImage: "linear-gradient($$underlineColor, $$underlineColor)",
backgroundPosition: "0% 100%",
backgroundRepeat: "no-repeat",
backgroundSize: "0% 2px",
paddingBottom: "3px",
"@media (prefers-reduced-motion: no-preference)": {
transition: `background-size ${theme.transitions.linkHover}`,
},
"&:hover, &:focus-visible": {
backgroundSize: "100% 2px",
},
},
false: {},
},
},
});
export type LinkProps = ComponentPropsWithoutRef<typeof StyledLink> & {
export type LinkProps = ComponentPropsWithoutRef<typeof NextLink> & {
underline?: boolean;
openInNewTab?: boolean;
};
const Link = ({ href, rel, target, prefetch = false, underline = true, openInNewTab, ...rest }: LinkProps) => {
const Link = ({
href,
rel,
target,
prefetch = false,
underline = true,
openInNewTab,
className,
...rest
}: LinkProps) => {
// This component auto-detects whether or not this link should open in the same window (the default for internal
// links) or a new tab (the default for external links). Defaults can be overridden with `openInNewTab={true}`.
const isExternal =
@@ -50,7 +31,7 @@ const Link = ({ href, rel, target, prefetch = false, underline = true, openInNew
if (openInNewTab || isExternal) {
return (
<StyledLink
<NextLink
href={href}
target={target || "_blank"}
rel={objStr({
@@ -58,14 +39,20 @@ const Link = ({ href, rel, target, prefetch = false, underline = true, openInNew
noopener: true,
noreferrer: isExternal, // don't add "noreferrer" if link isn't external, and only opening in a new tab
})}
underline={underline}
prefetch={false}
className={clsx(styles.link, underline && styles.underline, className)}
{...rest}
/>
);
}
// If link is to an internal page, simply pass *everything* along as-is to next/link.
return <StyledLink {...{ href, rel, target, prefetch, underline, ...rest }} />;
return (
<NextLink
className={clsx(styles.link, underline && styles.underline, className)}
{...{ href, rel, target, prefetch, ...rest }}
/>
);
};
export default Link;
+8
View File
@@ -0,0 +1,8 @@
.list {
margin-left: 1.5em;
padding-left: 0;
}
.listItem {
padding-left: 0.25em;
}
+13 -10
View File
@@ -1,15 +1,18 @@
import { styled, css } from "../../lib/styles/stitches.config";
import clsx from "clsx";
import type { ComponentPropsWithoutRef } from "react";
const ListStyles = css({
marginLeft: "1.5em",
paddingLeft: 0,
});
import styles from "./List.module.css";
export const UnorderedList = styled("ul", ListStyles);
export const OrderedList = styled("ol", ListStyles);
export const UnorderedList = ({ className, ...rest }: ComponentPropsWithoutRef<"ul">) => (
<ul className={clsx(styles.list, className)} {...rest} />
);
export const ListItem = styled("li", {
paddingLeft: "0.25em",
});
export const OrderedList = ({ className, ...rest }: ComponentPropsWithoutRef<"ol">) => (
<ol className={clsx(styles.list, className)} {...rest} />
);
export const ListItem = ({ className, ...rest }: ComponentPropsWithoutRef<"li">) => (
<li className={clsx(styles.listItem, className)} {...rest} />
);
export default UnorderedList;
+22
View File
@@ -0,0 +1,22 @@
.loading {
display: inline-block;
text-align: center;
}
.box {
display: inline-block;
height: 100%;
animation: loading 1.5s infinite ease-in-out both;
background-color: var(--colors-mediumLight);
}
@keyframes loading {
0%,
80%,
100% {
transform: scale(0);
}
40% {
transform: scale(0.6);
}
}
+12 -29
View File
@@ -1,32 +1,15 @@
import { styled, theme, keyframes } from "../../lib/styles/stitches.config";
import clsx from "clsx";
import type { ComponentPropsWithoutRef } from "react";
const Wrapper = styled("div", {
display: "inline-block",
textAlign: "center",
});
import styles from "./Loading.module.css";
const Box = styled("div", {
display: "inline-block",
height: "100%",
animation: `${keyframes({
"0%, 80%, 100%": {
transform: "scale(0)",
},
"40%": {
transform: "scale(0.6)",
},
})} 1.5s infinite ease-in-out both`,
backgroundColor: theme.colors.mediumLight,
});
export type LoadingProps = ComponentPropsWithoutRef<typeof Wrapper> & {
export type LoadingProps = ComponentPropsWithoutRef<"div"> & {
width: number; // of entire container, in pixels
boxes?: number; // total number of boxes (default: 3)
timing?: number; // staggered timing between each box's pulse, in seconds (default: 0.1s)
};
const Loading = ({ width, boxes = 3, timing = 0.1, css, ...rest }: LoadingProps) => {
const Loading = ({ width, boxes = 3, timing = 0.1, className, style, ...rest }: LoadingProps) => {
// each box is just an empty div
const divs = [];
@@ -35,12 +18,11 @@ const Loading = ({ width, boxes = 3, timing = 0.1, css, ...rest }: LoadingProps)
// width of each box correlates with number of boxes (with a little padding)
// each individual box's animation has a staggered start in corresponding order
divs.push(
<Box
<div
key={i}
css={{
width: `${width / (boxes + 1)}px`,
}}
className={styles.box}
style={{
width: `${width / (boxes + 1)}px`,
animationDelay: `${i * timing}s`,
}}
/>
@@ -48,16 +30,17 @@ const Loading = ({ width, boxes = 3, timing = 0.1, css, ...rest }: LoadingProps)
}
return (
<Wrapper
css={{
<div
className={clsx(styles.loading, className)}
style={{
width: `${width}px`,
height: `${width / 2}px`,
...css,
...style,
}}
{...rest}
>
{divs}
</Wrapper>
</div>
);
};
+34
View File
@@ -0,0 +1,34 @@
.menu {
display: inline-flex;
padding: 0;
margin: 0;
}
.menuItem {
display: inline-block;
margin-left: 1em;
list-style: none;
}
@media (max-width: 768px) {
.menu {
width: 100%;
justify-content: space-between;
margin-left: 1em;
}
.menuItem {
margin-left: 0;
}
}
@media (max-width: 380px) {
.menu {
margin-left: 1.4em;
}
/* the home icon is kinda redundant when space is SUPER tight */
.menuItem:first-of-type {
display: none;
}
}
+16 -45
View File
@@ -1,65 +1,36 @@
import { useRouter } from "next/router";
"use client";
import { usePathname } from "next/navigation";
import clsx from "clsx";
import MenuItem from "../MenuItem";
import ThemeToggle from "../ThemeToggle";
import { styled } from "../../lib/styles/stitches.config";
import { menuItems } from "../../lib/config/menu";
import type { ComponentPropsWithoutRef } from "react";
const Wrapper = styled("ul", {
display: "inline-flex",
padding: 0,
margin: 0,
import styles from "./Menu.module.css";
"@medium": {
width: "100%",
justifyContent: "space-between",
marginLeft: "1em",
},
export type MenuProps = ComponentPropsWithoutRef<"ul">;
"@small": {
marginLeft: "1.4em",
},
});
const Item = styled("li", {
display: "inline-block",
marginLeft: "1em",
listStyle: "none",
"@medium": {
marginLeft: 0,
},
"@small": {
// the home icon is kinda redundant when space is SUPER tight
"&:first-of-type": {
display: "none",
},
},
});
export type MenuProps = ComponentPropsWithoutRef<typeof Wrapper>;
const Menu = ({ ...rest }: MenuProps) => {
const router = useRouter();
const Menu = ({ className, ...rest }: MenuProps) => {
const pathname = usePathname() || "";
return (
<Wrapper {...rest}>
<ul className={clsx(styles.menu, className)} {...rest}>
{menuItems.map((item, index) => {
// kinda weird/hacky way to determine if the *first part* of the current path matches this href
const isCurrent = item.href === `/${router.pathname.split("/")[1]}`;
const isCurrent = item.href === `/${pathname.split("/")[1]}`;
return (
<Item key={item.text || index}>
<li className={styles.menuItem} key={item.text || index}>
<MenuItem {...item} current={isCurrent} />
</Item>
</li>
);
})}
<Item>
<MenuItem icon={ThemeToggle} />
</Item>
</Wrapper>
<li className={styles.menuItem}>
<MenuItem Icon={ThemeToggle} />
</li>
</ul>
);
};
+42
View File
@@ -0,0 +1,42 @@
.link {
display: inline-block;
color: var(--colors-mediumDark) !important;
padding: 0.6em;
}
/* indicate active page/section */
.link.current {
margin-bottom: -0.2em;
border-bottom: 0.2em solid var(--colors-linkUnderline);
}
.link:not(.current):hover,
.link:not(.current):focus-visible {
margin-bottom: -0.2em;
border-bottom: 0.2em solid var(--colors-kindaLight);
}
.icon {
display: inline-block;
width: 1.25em;
height: 1.25em;
vertical-align: -0.3em;
}
.label {
font-size: 0.925em;
font-weight: 500;
letter-spacing: 0.025em;
margin-left: 0.7em;
}
@media (max-width: 768px) {
.icon {
width: 1.8em;
height: 1.8em;
}
.label {
display: none;
}
}
+16 -52
View File
@@ -1,74 +1,38 @@
import clsx from "clsx";
import Link from "../Link";
import { styled, theme } from "../../lib/styles/stitches.config";
import type { Route } from "next";
import type { IconType } from "react-icons";
const MenuLink = styled(Link, {
display: "inline-block",
color: theme.colors.mediumDark,
padding: "0.6em",
variants: {
// indicate active page/section
current: {
true: {
marginBottom: "-0.2em",
borderBottom: `0.2em solid ${theme.colors.linkUnderline}`,
},
false: {
"&:hover, &:focus-visible": {
marginBottom: "-0.2em",
borderBottom: `0.2em solid ${theme.colors.kindaLight}`,
},
},
},
},
});
const Icon = styled("svg", {
display: "inline",
width: "1.25em",
height: "1.25em",
verticalAlign: "-0.3em",
"@medium": {
width: "1.8em",
height: "1.8em",
},
});
const Label = styled("span", {
fontSize: "0.925em",
fontWeight: 500,
letterSpacing: "0.025em",
marginLeft: "0.7em",
"@medium": {
display: "none",
},
});
import styles from "./MenuItem.module.css";
export type MenuItemProps = {
icon?: IconType;
Icon?: IconType;
text?: string;
href?: string;
href?: Route;
current?: boolean;
className?: string;
};
const MenuItem = ({ icon, text, href, current, className }: MenuItemProps) => {
const MenuItem = ({ Icon, text, href, current, className }: MenuItemProps) => {
const item = (
<>
{icon && <Icon as={icon} />}
{text && <Label>{text}</Label>}
{Icon && <Icon className={styles.icon} />}
{text && <span className={styles.label}>{text}</span>}
</>
);
// allow both navigational links and/or other interactive react components (e.g. the theme toggle)
if (href) {
return (
<MenuLink href={href} className={className} current={current} title={text} underline={false} aria-label={text}>
<Link
href={href}
className={clsx(styles.link, current && styles.current, className)}
title={text}
underline={false}
aria-label={text}
>
{item}
</MenuLink>
</Link>
);
}
@@ -0,0 +1,16 @@
.octocatLink {
margin: 0 0.4em;
color: var(--colors-text) !important;
}
.octocatLink:hover,
.octocatLink:focus-visible {
color: var(--colors-link) !important;
}
.octocat {
display: inline;
width: 1.2em;
height: 1.2em;
vertical-align: -0.2em;
}
+4 -21
View File
@@ -1,14 +1,9 @@
import Link from "../Link";
import { SiGithub } from "react-icons/si";
import { styled, theme } from "../../lib/styles/stitches.config";
import type { ComponentPropsWithoutRef } from "react";
const Octocat = styled(SiGithub, {
display: "inline",
width: "1.2em",
height: "1.2em",
verticalAlign: "-0.2em",
});
import styles from "./OctocatLink.module.css";
import clsx from "clsx";
export type OctocatLinkProps = Omit<ComponentPropsWithoutRef<typeof Link>, "href"> & {
repo: string;
@@ -16,20 +11,8 @@ export type OctocatLinkProps = Omit<ComponentPropsWithoutRef<typeof Link>, "href
const OctocatLink = ({ repo, className, ...rest }: OctocatLinkProps) => {
return (
<Link
href={`https://github.com/${repo}`}
underline={false}
css={{
margin: "0 0.4em",
color: theme.colors.text,
"&:hover, &:focus-visible": {
color: theme.colors.link,
},
}}
{...rest}
>
<Octocat className={className} />
<Link href={`https://github.com/${repo}`} underline={false} className={styles.octocatLink} {...rest}>
<SiGithub className={clsx(styles.octocat, className)} />
</Link>
);
};
+17
View File
@@ -0,0 +1,17 @@
.title {
margin-top: 0;
margin-bottom: 0.6em;
font-size: 1.7em;
font-weight: 600;
text-align: center;
}
.link {
color: var(--colors-text) !important;
}
@media (max-width: 768px) {
.title {
font-size: 1.8em;
}
}
+12 -19
View File
@@ -1,31 +1,24 @@
import { useRouter } from "next/router";
"use client";
import { usePathname } from "next/navigation";
import clsx from "clsx";
import Link from "../Link";
import { styled, theme } from "../../lib/styles/stitches.config";
import type { ComponentPropsWithoutRef } from "react";
import type { Route } from "next";
const Title = styled("h1", {
marginTop: 0,
marginBottom: "0.6em",
fontSize: "1.7em",
fontWeight: 600,
textAlign: "center",
import styles from "./PageTitle.module.css";
"@medium": {
fontSize: "1.8em",
},
});
export type PageTitleProps = ComponentPropsWithoutRef<"h1">;
export type PageTitleProps = ComponentPropsWithoutRef<typeof Title>;
const PageTitle = ({ children, ...rest }: PageTitleProps) => {
const router = useRouter();
const PageTitle = ({ className, children, ...rest }: PageTitleProps) => {
const pathname = usePathname() || "";
return (
<Title {...rest}>
<Link href={router.pathname} underline={false} css={{ color: theme.colors.text }}>
<h1 className={clsx(styles.title, className)} {...rest}>
<Link href={pathname as Route} underline={false} className={styles.link}>
{children}
</Link>
</Title>
</h1>
);
};
-125
View File
@@ -1,125 +0,0 @@
import { ErrorBoundary } from "react-error-boundary";
import Link from "../Link";
import Time from "../Time";
import HitCounter from "../HitCounter";
import PostTitle from "../PostTitle";
import { FiCalendar, FiTag, FiEdit, FiEye } from "react-icons/fi";
import { styled, theme } from "../../lib/styles/stitches.config";
import config from "../../lib/config";
import type { PostFrontMatter } from "../../types";
const Wrapper = styled("div", {
display: "inline-flex",
flexWrap: "wrap",
fontSize: "0.825em",
lineHeight: 2.3,
letterSpacing: "0.04em",
color: theme.colors.medium,
});
const MetaItem = styled("div", {
marginRight: "1.6em",
whiteSpace: "nowrap",
});
const MetaLink = styled(Link, {
color: "inherit",
});
const Icon = styled("svg", {
display: "inline",
width: "1.2em",
height: "1.2em",
verticalAlign: "-0.2em",
marginRight: "0.6em",
});
const TagsList = styled("span", {
whiteSpace: "normal",
display: "inline-flex",
flexWrap: "wrap",
});
const Tag = styled("span", {
textTransform: "lowercase",
whiteSpace: "nowrap",
marginRight: "0.75em",
"&::before": {
content: "\\0023", // cosmetically hashtagify tags
paddingRight: "0.125em",
color: theme.colors.light,
},
"&:last-of-type": {
marginRight: 0,
},
});
export type PostMetaProps = Pick<PostFrontMatter, "slug" | "date" | "title" | "htmlTitle" | "tags">;
const PostMeta = ({ slug, date, title, htmlTitle, tags }: PostMetaProps) => {
return (
<>
<Wrapper>
<MetaItem>
<MetaLink
href={{
pathname: "/notes/[slug]/",
query: { slug },
}}
underline={false}
>
<Icon as={FiCalendar} />
<Time date={date} format="MMMM D, YYYY" />
</MetaLink>
</MetaItem>
{tags && (
<MetaItem>
<Icon as={FiTag} />
<TagsList>
{tags.map((tag) => (
<Tag key={tag} title={tag} aria-label={`Tagged with ${tag}`}>
{tag}
</Tag>
))}
</TagsList>
</MetaItem>
)}
<MetaItem>
<MetaLink
href={`https://github.com/${config.githubRepo}/blob/main/notes/${slug}.mdx`}
title={`Edit "${title}" on GitHub`}
underline={false}
>
<Icon as={FiEdit} />
<span>Improve This Post</span>
</MetaLink>
</MetaItem>
{/* only count hits on production site */}
{process.env.NEXT_PUBLIC_VERCEL_ENV === "production" && (
<MetaItem
css={{
// fix potential layout shift when number of hits loads
minWidth: "7em",
marginRight: 0,
}}
>
{/* completely hide this block if anything goes wrong on the backend */}
<ErrorBoundary fallback={null}>
<Icon as={FiEye} />
<HitCounter slug={`notes/${slug}`} />
</ErrorBoundary>
</MetaItem>
)}
</Wrapper>
<PostTitle {...{ slug, title, htmlTitle }} />
</>
);
};
export default PostMeta;
-2
View File
@@ -1,2 +0,0 @@
export * from "./PostMeta";
export { default } from "./PostMeta";
-40
View File
@@ -1,40 +0,0 @@
import Link from "../Link";
import { styled, theme } from "../../lib/styles/stitches.config";
import type { ComponentPropsWithoutRef } from "react";
import type { PostFrontMatter } from "../../types";
const Title = styled("h1", {
margin: "0.3em 0 0.5em -1px", // misaligned left margin, super nitpicky
fontSize: "2.1em",
lineHeight: 1.3,
fontWeight: 700,
"& code": {
margin: "0 0.075em",
},
"@medium": {
fontSize: "1.8em",
},
});
export type PostTitleProps = Pick<PostFrontMatter, "slug" | "title" | "htmlTitle"> &
ComponentPropsWithoutRef<typeof Title>;
const PostTitle = ({ slug, title, htmlTitle, ...rest }: PostTitleProps) => {
return (
<Title {...rest}>
<Link
href={{
pathname: "/notes/[slug]/",
query: { slug },
}}
dangerouslySetInnerHTML={{ __html: htmlTitle || title }}
underline={false}
css={{ color: theme.colors.text }}
/>
</Title>
);
};
export default PostTitle;
-2
View File
@@ -1,2 +0,0 @@
export * from "./PostTitle";
export { default } from "./PostTitle";
-95
View File
@@ -1,95 +0,0 @@
import Link from "../Link";
import Time from "../Time";
import { styled, theme } from "../../lib/styles/stitches.config";
import type { ReactElement } from "react";
import type { PostsByYear } from "../../types";
const Section = styled("section", {
fontSize: "1.1em",
lineHeight: 1.1,
margin: "2.4em 0",
"&:first-of-type": {
marginTop: 0,
},
"&:last-of-type": {
marginBottom: 0,
},
"@medium": {
margin: "1.8em 0",
},
});
const Year = styled("h2", {
fontSize: "2.2em",
fontWeight: 700,
marginTop: 0,
marginBottom: "0.5em",
"@medium": {
fontSize: "2em",
},
});
const List = styled("ul", {
listStyleType: "none",
margin: 0,
padding: 0,
});
const Post = styled("li", {
display: "flex",
lineHeight: 1.75,
marginBottom: "1em",
"&:last-of-type": {
marginBottom: 0,
},
});
const PostDate = styled(Time, {
width: "5.25em",
flexShrink: 0,
color: theme.colors.medium,
});
export type PostsListProps = {
postsByYear: PostsByYear;
};
const PostsList = ({ postsByYear }: PostsListProps) => {
const sections: ReactElement[] = [];
Object.entries(postsByYear).forEach(([year, posts]) => {
sections.push(
<Section key={year}>
<Year>{year}</Year>
<List>
{posts.map(({ slug, date, title, htmlTitle }) => (
<Post key={slug}>
<PostDate date={date} format="MMM D" />
<span>
<Link
href={{
pathname: "/notes/[slug]/",
query: { slug },
}}
dangerouslySetInnerHTML={{ __html: htmlTitle || title }}
/>
</span>
</Post>
))}
</List>
</Section>
);
});
// grouped posts enter this component ordered chronologically -- we want reverse chronological
const reversed = sections.reverse();
return <>{reversed}</>;
};
export default PostsList;
-2
View File
@@ -1,2 +0,0 @@
export * from "./PostsList";
export { default } from "./PostsList";
+2
View File
@@ -1,3 +1,5 @@
"use client";
import useHasMounted from "../../hooks/useHasMounted";
import { formatDate, formatTimeAgo } from "../../lib/helpers/format-date";
@@ -1,131 +0,0 @@
import commaNumber from "comma-number";
import Link from "../Link";
import RelativeTime from "../RelativeTime";
import { GoStar, GoRepoForked } from "react-icons/go";
import { styled, theme } from "../../lib/styles/stitches.config";
import type { Project } from "../../types";
const Wrapper = styled("div", {
width: "100%",
padding: "1.2em 1.2em 0.8em 1.2em",
border: `1px solid ${theme.colors.kindaLight}`,
borderRadius: theme.radii.corner,
fontSize: "0.85em",
color: theme.colors.mediumDark,
transition: `border ${theme.transitions.fade}`,
});
const Name = styled(Link, {
fontSize: "1.2em",
fontWeight: 600,
});
const Description = styled("p", {
marginTop: "0.7em",
marginBottom: "0.5em",
lineHeight: 1.7,
});
const Meta = styled("div", {
display: "flex",
flexWrap: "wrap",
alignItems: "baseline",
});
const MetaItem = styled("div", {
marginRight: "1.5em",
lineHeight: 2,
color: theme.colors.medium,
});
const MetaLink = styled(Link, {
color: "inherit",
"&:hover, &:focus-visible": {
color: theme.colors.link,
},
});
const MetaIcon = styled("svg", {
display: "inline",
width: "16px",
height: "16px",
verticalAlign: "-0.3em",
marginRight: "0.5em",
strokeWidth: 0.75,
});
const LanguageCircle = styled("span", {
display: "inline-block",
position: "relative",
width: "1.15em",
height: "1.15em",
marginRight: "0.5em",
borderRadius: "50%",
verticalAlign: "text-top",
});
export type RepositoryCardProps = Project & {
className?: string;
};
const RepositoryCard = ({
name,
url,
description,
language,
stars,
forks,
updatedAt,
className,
}: RepositoryCardProps) => {
return (
<Wrapper className={className}>
<Name href={url}>{name}</Name>
{description && <Description>{description}</Description>}
<Meta>
{language && (
<MetaItem>
{language.color && <LanguageCircle css={{ backgroundColor: language.color }} />}
{language.name}
</MetaItem>
)}
{stars && stars > 0 && (
<MetaItem>
<MetaLink
href={`${url}/stargazers`}
title={`${commaNumber(stars)} ${stars === 1 ? "star" : "stars"}`}
underline={false}
>
<MetaIcon as={GoStar} />
{commaNumber(stars)}
</MetaLink>
</MetaItem>
)}
{forks && forks > 0 && (
<MetaItem>
<MetaLink
href={`${url}/network/members`}
title={`${commaNumber(forks)} ${forks === 1 ? "fork" : "forks"}`}
underline={false}
>
<MetaIcon as={GoRepoForked} />
{commaNumber(forks)}
</MetaLink>
</MetaItem>
)}
{/* only use relative "time ago" on client side, since it'll be outdated via SSG and cause hydration errors */}
<MetaItem>
<RelativeTime date={updatedAt} verb="Updated" staticFormat="MMM D, YYYY" />
</MetaItem>
</Meta>
</Wrapper>
);
};
export default RepositoryCard;
-2
View File
@@ -1,2 +0,0 @@
export * from "./RepositoryCard";
export { default } from "./RepositoryCard";
-71
View File
@@ -1,71 +0,0 @@
import Link from "../Link";
import Image from "../Image";
import { styled, theme } from "../../lib/styles/stitches.config";
import config from "../../lib/config";
import type { ComponentPropsWithoutRef } from "react";
import selfieJpg from "../../public/static/images/selfie.jpg";
const CircleImage = styled(Image, {
width: "50px",
height: "50px",
border: `1px solid ${theme.colors.light}`,
borderRadius: "50%",
"@medium": {
width: "70px",
height: "70px",
borderWidth: "2px",
},
});
const SelfieLink = styled(Link, {
display: "inline-flex",
flexShrink: 0,
alignItems: "center",
color: theme.colors.mediumDark,
"&:hover, &:focus-visible": {
color: theme.colors.link,
"@medium": {
[`${CircleImage}`]: {
borderColor: theme.colors.linkUnderline,
},
},
},
});
const Name = styled("span", {
margin: "0 0.6em",
fontSize: "1.15em",
fontWeight: 500,
letterSpacing: "0.02em",
lineHeight: 1,
"@medium": {
display: "none",
},
});
export type SelfieProps = Omit<ComponentPropsWithoutRef<typeof Link>, "href">;
const Selfie = ({ ...rest }: SelfieProps) => {
return (
<SelfieLink href="/" rel="author" title={config.authorName} underline={false} {...rest}>
<CircleImage
src={selfieJpg}
alt={`Photo of ${config.authorName}`}
width={70}
height={70}
quality={60}
placeholder="empty"
inline
priority
/>
<Name>{config.authorName}</Name>
</SelfieLink>
);
};
export default Selfie;
-2
View File
@@ -1,2 +0,0 @@
export * from "./Selfie";
export { default } from "./Selfie";
@@ -0,0 +1,29 @@
/* accessible invisibility stuff pulled from @reach/skip-nav:
https://github.com/reach/reach-ui/blob/main/packages/skip-nav/styles.css */
.hiddenLink {
border: 0;
clip: rect(0 0 0 0);
height: 1px;
width: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
position: absolute;
}
.hiddenLink:focus {
padding: 1rem;
position: fixed;
top: 10px;
left: 10px;
z-index: 99999;
width: auto;
height: auto;
clip: auto;
background: var(--colors-superDuperLight);
color: var(--colors-link);
border: 2px solid var(--colors-kindaLight);
border-radius: var(--radii-corner);
text-decoration: underline;
}
+3 -32
View File
@@ -1,41 +1,12 @@
import { styled, theme } from "../../lib/styles/stitches.config";
const HiddenLink = styled("a", {
// accessible invisibility stuff pulled from @reach/skip-nav:
// https://github.com/reach/reach-ui/blob/main/packages/skip-nav/styles.css
border: 0,
clip: "rect(0 0 0 0)",
height: "1px",
width: "1px",
margin: "-1px",
padding: 0,
overflow: "hidden",
position: "absolute",
"&:focus": {
padding: "1rem",
position: "fixed",
top: "10px",
left: "10px",
zIndex: 99999,
width: "auto",
height: "auto",
clip: "auto",
background: theme.colors.superDuperLight,
color: theme.colors.link,
border: `2px solid ${theme.colors.kindaLight}`,
borderRadius: theme.radii.corner,
textDecoration: "underline",
},
});
import styles from "./SkipToContent.module.css";
const skipNavId = "skip-nav";
export const SkipToContentLink = () => {
return (
<HiddenLink href={`#${skipNavId}`} tabIndex={0}>
<a href={`#${skipNavId}`} tabIndex={0} className={styles.hiddenLink}>
Skip to content
</HiddenLink>
</a>
);
};
-46
View File
@@ -1,46 +0,0 @@
import { keyframes, styled } from "../../lib/styles/stitches.config";
import type { ComponentPropsWithoutRef } from "react";
const BlackBox = styled("div", {
width: "100%",
height: "100%",
padding: "1.25em",
backgroundColor: "#000000",
color: "#cccccc",
});
const Monospace = styled("pre", {
display: "inline",
margin: 0,
lineHeight: 1.75,
fontSize: "0.925em",
fontWeight: 500,
whiteSpace: "pre-wrap",
userSelect: "none",
});
// flashing terminal cursor underscore-looking thingy
const Underscore = styled("span", {
display: "inline-block",
verticalAlign: "text-bottom",
width: "10px",
marginLeft: "6px",
borderBottom: "2px solid #cccccc",
// blink every second for 0.4s
animation: `${keyframes({ "40%": { opacity: 0 } })} 1s step-end infinite`,
});
export type TerminalProps = ComponentPropsWithoutRef<typeof BlackBox>;
// a DOS-style terminal box with dynamic text
const Terminal = ({ children: message, ...rest }: TerminalProps) => {
return (
<BlackBox {...rest}>
{message && <Monospace>{message}</Monospace>}
<Underscore />
</BlackBox>
);
};
export default Terminal;
-2
View File
@@ -1,2 +0,0 @@
export * from "./Terminal";
export { default } from "./Terminal";
@@ -0,0 +1,13 @@
.toggle {
border: 0;
padding: 0.6em;
margin-right: -0.6em;
background: none;
cursor: pointer;
color: var(--colors-mediumDark);
}
.toggle:hover,
.toggle:focus-visible {
color: var(--colors-warning);
}
+13 -21
View File
@@ -1,23 +1,12 @@
"use client";
import { useEffect, useId } from "react";
import { useSpring, animated, Globals } from "@react-spring/web";
import useMedia from "../../hooks/useMedia";
import { animated, Globals, useSpring, useReducedMotion } from "@react-spring/web";
import useFirstMountState from "../../hooks/useFirstMountState";
import useTheme from "../../hooks/useTheme";
import useHasMounted from "../../hooks/useHasMounted";
import { styled, theme } from "../../lib/styles/stitches.config";
const Button = styled("button", {
border: 0,
padding: "0.6em",
marginRight: "-0.6em",
background: "none",
cursor: "pointer",
color: theme.colors.mediumDark,
"&:hover, &:focus-visible": {
color: theme.colors.warning,
},
});
import styles from "./ThemeToggle.module.css";
export type ThemeToggleProps = {
className?: string;
@@ -27,7 +16,7 @@ const ThemeToggle = ({ className }: ThemeToggleProps) => {
const hasMounted = useHasMounted();
const { activeTheme, setTheme } = useTheme();
const isFirstMount = useFirstMountState();
const prefersReducedMotion = useMedia("(prefers-reduced-motion: reduce)", false);
const prefersReducedMotion = useReducedMotion() ?? false;
const maskId = useId(); // SSR-safe ID to cross-reference areas of the SVG
// default to light since `activeTheme` might be undefined
@@ -36,7 +25,7 @@ const ThemeToggle = ({ className }: ThemeToggleProps) => {
// accessibility: disable animation if user prefers reduced motion
useEffect(() => {
Globals.assign({
skipAnimation: !!isFirstMount || !!prefersReducedMotion,
skipAnimation: isFirstMount || prefersReducedMotion,
});
}, [isFirstMount, prefersReducedMotion]);
@@ -97,18 +86,20 @@ const ThemeToggle = ({ className }: ThemeToggleProps) => {
// render a blank div of the same size to avoid layout shifting until we're fully mounted and self-aware
if (!hasMounted) {
return (
<Button as="div">
<div className={styles.toggle}>
<div className={className} />
</Button>
</div>
);
}
return (
<Button
<button
onClick={() => setTheme(safeTheme === "light" ? "dark" : "light")}
className={styles.toggle}
title={safeTheme === "light" ? "Toggle Dark Mode" : "Toggle Light Mode"}
aria-label={safeTheme === "light" ? "Toggle Dark Mode" : "Toggle Light Mode"}
>
{/* @ts-ignore */}
<animated.svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
@@ -146,6 +137,7 @@ const ThemeToggle = ({ className }: ThemeToggleProps) => {
/>
{/* sunrays pulled from https://github.com/feathericons/feather/blob/734f3f51144e383cfdc6d0916831be8d1ad2a749/icons/sun.svg?short_path=fea872c#L13 */}
{/* @ts-ignore */}
<animated.g stroke="currentColor" style={linesProps}>
<line x1="12" y1="1" x2="12" y2="3" />
<line x1="12" y1="21" x2="12" y2="23" />
@@ -157,7 +149,7 @@ const ThemeToggle = ({ className }: ThemeToggleProps) => {
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
</animated.g>
</animated.svg>
</Button>
</button>
);
};
@@ -0,0 +1,8 @@
.tweet {
/* help with layout shift */
min-height: 300px;
}
.tweet :global(.react-tweet-theme) {
--tweet-container-margin: 1.5rem auto;
}
+6 -24
View File
@@ -1,39 +1,21 @@
import { useEffect, useRef } from "react";
import Image from "next/image";
import { Tweet } from "react-tweet";
import useTheme from "../../hooks/useTheme";
import { styled } from "../../lib/styles/stitches.config";
import type { ComponentPropsWithoutRef, ElementRef } from "react";
import clsx from "clsx";
import type { ComponentPropsWithoutRef } from "react";
const Wrapper = styled("div", {
minHeight: "300px", // help with layout shift
"& .react-tweet-theme": {
"--tweet-container-margin": "1.5rem auto",
},
});
import styles from "./TweetEmbed.module.css";
export type TweetEmbedProps = ComponentPropsWithoutRef<typeof Tweet> & {
id: string;
className?: string;
};
const TweetEmbed = ({ id, className, ...rest }: TweetEmbedProps) => {
const containerRef = useRef<ElementRef<typeof Wrapper>>(null);
const { activeTheme } = useTheme();
useEffect(() => {
if (containerRef.current) {
// setting 'data-theme' attribute of parent div changes the tweet's theme (no re-render necessary)
containerRef.current.dataset.theme = activeTheme;
}
}, [activeTheme]);
return (
<Wrapper ref={containerRef} className={className}>
<div className={clsx(styles.tweet, className)}>
<Tweet
key={`tweet-${id}`}
id={id}
apiUrl={`/api/tweet/?id=${id}`} // edge function at pages/api/tweet.ts
components={{
// https://react-tweet.vercel.app/twitter-theme/api-reference#custom-tweet-components
// eslint-disable-next-line jsx-a11y/alt-text
@@ -43,7 +25,7 @@ const TweetEmbed = ({ id, className, ...rest }: TweetEmbedProps) => {
}}
{...rest}
/>
</Wrapper>
</div>
);
};
-178
View File
@@ -1,178 +0,0 @@
import { useRef, useEffect, useState, forwardRef, useImperativeHandle, useCallback } from "react";
import { useRouter } from "next/router";
import RFB from "@novnc/novnc/core/rfb";
import Terminal from "../Terminal";
import { styled } from "../../lib/styles/stitches.config";
import type { Ref, ComponentPropsWithoutRef, ElementRef } from "react";
const Display = styled(
"div",
{
height: "600px",
width: "100%",
maxWidth: "800px",
// these are injected by noVNC after connection, so we can't target them directly:
"& div": {
background: "none !important",
"& canvas": {
cursor: "inherit !important",
},
},
},
// fix fuziness in different browsers: https://stackoverflow.com/a/13492784
// separate objects since these are duplicate properties: https://github.com/modulz/stitches/issues/758#issuecomment-913580518
{
imageRendering: "-webkit-optimize-contrast",
},
{
imageRendering: "pixelated",
MSInterpolationMode: "nearest-neighbor",
}
);
export type VNCProps = ComponentPropsWithoutRef<typeof Display> & {
server: string;
};
const VNC = ({ server, style, ...rest }: VNCProps, ref: Ref<Partial<RFB>>) => {
const router = useRouter();
// we definitely do NOT want this page to connect more than once!
const [loaded, setLoaded] = useState(false);
// keep track of current status of the connection
const [connected, setConnected] = useState(false);
// makes the console reappear with the given message if there's an error loading, or if the VM has gone poof for
// whatever reason (doesn't really matter).
const [message, setMessage] = useState({ message: "", anyKey: false });
// the actual connection and virtual screen (injected by noVNC when it's ready)
const rfbRef = useRef<RFB | null>(null);
const displayRef = useRef<ElementRef<typeof Display>>(null);
// ends the session forcefully
const disconnect = useCallback(() => {
try {
if (connected) {
rfbRef.current?.disconnect();
}
} catch (error) {} // eslint-disable-line no-empty, @typescript-eslint/no-unused-vars
rfbRef.current = null;
setConnected(false);
}, [connected]);
// expose some of noVNC's functionality to the parent of this component
useImperativeHandle(ref, () => ({
rfb: rfbRef?.current,
disconnect,
focus: () => {
rfbRef.current?.focus();
},
blur: () => {
rfbRef.current?.blur();
},
sendCtrlAltDel: () => {
rfbRef.current?.sendCtrlAltDel();
},
machineShutdown: () => {
rfbRef.current?.machineShutdown();
},
machineReboot: () => {
rfbRef.current?.machineReboot();
},
machineReset: () => {
rfbRef.current?.machineReset();
},
clipboardPasteFrom: (text: string) => {
rfbRef.current?.clipboardPasteFrom(text);
},
connected,
}));
// prepare for possible navigation away from this page, and disconnect if/when it happens
useEffect(() => {
router.events.on("routeChangeStart", disconnect);
return () => {
// unassign event listener
router.events.off("routeChangeStart", disconnect);
};
}, [router.events, disconnect]);
useEffect(() => {
if (loaded) {
// don't do any of this more than once, the backend is pretty fragile
return;
}
if (!("WebSocket" in window)) {
// browser doesn't support websockets
setMessage({ message: "WebSockets must be enabled to begin!", anyKey: true });
return;
}
// show loading indicator and continue
setMessage({ message: "Spinning up your very own personal computer, please wait!", anyKey: false });
// this is the one and only time we're spinning up a VM (hopefully)
setLoaded(true);
// https://github.com/novnc/noVNC/blob/master/docs/API.md
rfbRef.current = new RFB(displayRef.current as Element, server, {
wsProtocols: ["binary", "base64"],
});
// scale screen to make it kinda "responsive"
rfbRef.current.scaleViewport = true;
// VM connected
rfbRef.current?.addEventListener("connect", () => {
console.log("successfully connected to VM socket!");
// finally hide the terminal and show the VNC canvas
setConnected(true);
});
// VM disconnected (on either end)
rfbRef.current?.addEventListener("disconnect", (detail: unknown) => {
console.warn("VM ended session remotely:", detail);
// hide the display and show the terminal
setConnected(false);
// apologize :(
setMessage({ message: "Oh dear, it looks like something's gone very wrong. Sorry about that.", anyKey: true });
});
}, [loaded, server]);
return (
<>
{!connected && (
<Terminal
css={{
height: "400px",
width: "100%",
maxWidth: "700px",
}}
>
{message.message}
{message.anyKey && "\n\nPress the Any key (refresh the page) to continue."}
</Terminal>
)}
{/* the VNC canvas is hidden until we've successfully connected to the socket */}
<Display
ref={displayRef}
style={{
display: !connected ? "none" : undefined,
...style,
}}
{...rest}
/>
</>
);
};
export default forwardRef(VNC);
-2
View File
@@ -1,2 +0,0 @@
export * from "./VNC";
export { default } from "./VNC";
+18
View File
@@ -0,0 +1,18 @@
.player {
margin: 0 auto;
}
.player video {
border-radius: var(--radii-corner);
}
.wrapper.responsive {
position: relative;
padding-top: 56.25%; /* ratio of 1280x720 */
}
.wrapper.responsive .player {
position: absolute;
top: 0;
left: 0;
}
+24 -82
View File
@@ -1,40 +1,9 @@
import ReactPlayer from "react-player/file";
import useHasMounted from "../../hooks/useHasMounted";
import { styled, theme } from "../../lib/styles/stitches.config";
import type { SourceProps } from "react-player/base";
import type { FilePlayerProps } from "react-player/file";
import clsx from "clsx";
import type { ComponentPropsWithoutRef } from "react";
const Player = styled(ReactPlayer, {
"& video": {
borderRadius: theme.radii.corner,
},
});
import styles from "./Video.module.css";
const Wrapper = styled("div", {
variants: {
// determines placement of the player. true expands to full width while keeping the aspect ratio, false retains the
// video's native dimensions (but still shrinks if the parent is narrower than the video).
responsive: {
true: {
position: "relative",
paddingTop: "56.25%", // ratio of 1280x720
[`& ${Player}`]: {
position: "absolute",
top: 0,
left: 0,
},
},
false: {
[`& ${Player}`]: {
margin: "0 auto",
},
},
},
},
});
export type VideoProps = Partial<FilePlayerProps> & {
export type VideoProps = Omit<Partial<ComponentPropsWithoutRef<"video">>, "src"> & {
src: {
// at least one is required:
webm?: string;
@@ -43,63 +12,36 @@ export type VideoProps = Partial<FilePlayerProps> & {
vtt?: string;
image?: string;
};
title?: string;
autoplay?: boolean;
responsive?: boolean;
className?: string;
};
const Video = ({ src, title, autoplay = false, responsive = true, className, ...rest }: VideoProps) => {
// fix hydration issues: https://github.com/cookpete/react-player/issues/1428
const hasMounted = useHasMounted();
const playerProps: Required<Pick<FilePlayerProps, "config">> & { url: SourceProps[] } = {
url: [],
config: {
attributes: {
controlsList: "nodownload",
preload: "metadata",
poster: src.image, // thumbnail
title: title,
autoPlay: autoplay,
loop: autoplay,
muted: autoplay, // no sound when autoplaying
controls: !autoplay, // only show controls when not autoplaying
},
tracks: [],
},
};
const Video = ({ src, autoplay = false, responsive = true, className, ...rest }: VideoProps) => {
if (!src || (!src.mp4 && !src.webm)) {
throw new Error("'src' prop must include either 'mp4' or 'webm' URL.");
}
if (src.webm) {
playerProps.url.push({
src: src.webm,
type: "video/webm",
});
}
if (src.mp4) {
playerProps.url.push({
src: src.mp4,
type: "video/mp4",
});
}
if (src.vtt) {
playerProps.config.tracks?.push({
kind: "subtitles",
src: src.vtt,
srcLang: "en",
label: "English",
default: true,
});
}
return (
<Wrapper responsive={responsive} className={className}>
{hasMounted && <Player width="100%" height="100%" {...playerProps} {...rest} />}
</Wrapper>
<div className={clsx(styles.wrapper, responsive && styles.responsive, className)}>
<video
width="100%"
height="100%"
className={styles.player}
preload={autoplay ? "auto" : "metadata"}
controls={!autoplay}
autoPlay={autoplay || undefined}
playsInline={autoplay} // safari autoplay workaround
loop={autoplay || undefined}
muted={autoplay || undefined}
poster={src.image}
{...rest}
>
{src.webm && <source key={src.webm} src={src.webm} type="video/webm" />}
{src.mp4 && <source key={src.mp4} src={src.mp4} type="video/mp4" />}
{src.vtt && <track key={src.vtt} kind="subtitles" src={src.vtt} srcLang="en" label="English" default />}
</video>
</div>
);
};
@@ -0,0 +1,15 @@
.wrapper {
position: relative;
padding-top: 56.25%; /* ratio of 1280x720 */
}
.player {
position: absolute;
top: 0;
left: 0;
}
.player .react-player__preview,
.player iframe {
border-radius: var(--radii-corner);
}
+16 -36
View File
@@ -1,46 +1,26 @@
import ReactPlayer from "react-player/youtube";
import useHasMounted from "../../hooks/useHasMounted";
import { styled, theme } from "../../lib/styles/stitches.config";
import type { YouTubePlayerProps } from "react-player/youtube";
import clsx from "clsx";
import type { ComponentPropsWithoutRef } from "react";
const Wrapper = styled("div", {
position: "relative",
paddingTop: "56.25%",
});
import styles from "./YouTubeEmbed.module.css";
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: theme.radii.corner,
},
});
export type YouTubeEmbedProps = Partial<YouTubePlayerProps> & {
export type YouTubeEmbedProps = ComponentPropsWithoutRef<"div"> & {
id: string;
className?: string;
};
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>
<div className={clsx(styles.wrapper, className)} {...rest}>
<iframe
src={`https://www.youtube-nocookie.com/embed/${id}`}
className={styles.player}
width="100%"
height="100%"
frameBorder="0"
loading="lazy"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
></iframe>
</div>
);
};