mirror of
https://github.com/jakejarvis/jarv.is.git
synced 2026-07-25 03:55:58 -04:00
Migrate to app router (#2254)
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
.blockquote {
|
||||
margin-left: 0;
|
||||
padding-left: 1.25em;
|
||||
border-left: 0.25em solid var(--colors-link);
|
||||
color: var(--colors-mediumDark);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./Captcha";
|
||||
export { default } from "./Captcha";
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
.comments {
|
||||
margin-top: 2em;
|
||||
padding-top: 2em;
|
||||
border-top: 2px solid var(--colors-light);
|
||||
min-height: 360px;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./ContactForm";
|
||||
export { default } from "./ContactForm";
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import Frame from "react-frame-component";
|
||||
import useHasMounted from "../../hooks/useHasMounted";
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
.iframe {
|
||||
width: 100%;
|
||||
display: block;
|
||||
margin: 1em auto;
|
||||
border: 2px solid var(--colors-kindaLight);
|
||||
border-radius: var(--radii-corner);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
.list {
|
||||
margin-left: 1.5em;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.listItem {
|
||||
padding-left: 0.25em;
|
||||
}
|
||||
+13
-10
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./PostMeta";
|
||||
export { default } from "./PostMeta";
|
||||
@@ -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;
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./PostTitle";
|
||||
export { default } from "./PostTitle";
|
||||
@@ -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;
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./PostsList";
|
||||
export { default } from "./PostsList";
|
||||
@@ -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;
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./RepositoryCard";
|
||||
export { default } from "./RepositoryCard";
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./VNC";
|
||||
export { default } from "./VNC";
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user