1
mirror of https://github.com/jakejarvis/jarv.is.git synced 2025-06-30 22:46:39 -04:00

CSS modules ➡️ Stitches 🧵 (#799)

This commit is contained in:
2022-03-03 09:18:26 -05:00
committed by GitHub
parent ac7ac71c10
commit c2dde042b7
93 changed files with 2392 additions and 3000 deletions

View File

@ -1,6 +0,0 @@
.blockquote {
margin-left: 0;
padding-left: 1.25em;
border-left: 0.25em solid var(--link);
color: var(--medium-dark);
}

View File

@ -1,11 +1,10 @@
import classNames from "classnames";
import { styled } from "../../lib/styles/stitches.config";
import styles from "./Blockquote.module.css";
export type BlockquoteProps = JSX.IntrinsicElements["blockquote"];
const Blockquote = ({ className, ...rest }: BlockquoteProps) => (
<blockquote className={classNames(styles.blockquote, className)} {...rest} />
);
const Blockquote = styled("blockquote", {
marginLeft: 0,
paddingLeft: "1.25em",
borderLeft: "0.25em solid $link",
color: "$mediumDark",
});
export default Blockquote;

View File

@ -7,6 +7,7 @@ export type CaptchaProps = {
size?: "normal" | "compact" | "invisible";
theme?: "light" | "dark";
id?: string;
className?: string;
// callbacks pulled verbatim from node_modules/@hcaptcha/react-hcaptcha/types/index.d.ts
/* eslint-disable @typescript-eslint/no-explicit-any */
@ -20,7 +21,7 @@ export type CaptchaProps = {
/* eslint-enable @typescript-eslint/no-explicit-any */
};
const Captcha = ({ size = "normal", theme, id, ...rest }: CaptchaProps) => {
const Captcha = ({ size = "normal", theme, id, className, ...rest }: CaptchaProps) => {
const { resolvedTheme } = useTheme();
return (
@ -30,15 +31,17 @@ const Captcha = ({ size = "normal", theme, id, ...rest }: CaptchaProps) => {
<link rel="preconnect" href="https://newassets.hcaptcha.com" />
</Head>
<HCaptcha
sitekey={process.env.NEXT_PUBLIC_HCAPTCHA_SITE_KEY}
reCaptchaCompat={false}
tabIndex={0}
size={size}
theme={theme || (resolvedTheme === "dark" ? "dark" : "light")}
id={id}
{...rest}
/>
<div className={className}>
<HCaptcha
sitekey={process.env.NEXT_PUBLIC_HCAPTCHA_SITE_KEY}
reCaptchaCompat={false}
tabIndex={0}
size={size}
theme={theme || (resolvedTheme === "dark" ? "dark" : "light")}
id={id}
{...rest}
/>
</div>
</>
);
};

View File

@ -1,133 +0,0 @@
/* all code */
.code {
font-size: 0.925em;
color: var(--code-text);
page-break-inside: avoid;
background-color: var(--code-background);
border: 1px solid var(--kinda-light);
border-radius: var(--rounded-edge-radius);
/* light-dark theme switch fading */
transition: background 0.25s ease, border 0.25s ease;
}
/* inline code in paragraphs/elsewhere (single backticks) */
.inline {
padding: 0.2em 0.3em;
}
/* full-blown code blocks, with copy/paste button and (usually) line numbers */
.block {
position: relative;
width: 100%;
margin: 1em auto;
}
.block .code {
display: block;
overflow-x: auto;
padding: 1em;
tab-size: 2;
}
.copy_btn {
position: absolute;
top: 0;
right: 0;
padding: 0.65em;
color: var(--medium-dark);
background-color: var(--background-inner);
border: 1px solid var(--kinda-light);
border-top-right-radius: var(--rounded-edge-radius);
border-end-start-radius: var(--rounded-edge-radius);
/* light-dark theme switch fading */
transition: background 0.25s ease, border 0.25s ease;
}
.copy_btn:hover {
color: var(--link);
}
/* the following sub-classes MUST be global -- the prism rehype plugin isn't aware of this file */
/* leave room for clipboard button to the right of the first line */
.highlight > :global(.code-line:first-of-type) {
margin-right: 3em;
}
.highlight > :global(.line-number::before) {
display: inline-block;
width: 1.5em;
margin-right: 1.5em;
text-align: right;
color: var(--code-comment);
content: attr(line); /* added to spans by prism */
}
.highlight :global(.token.comment),
.highlight :global(.token.prolog),
.highlight :global(.token.cdata) {
color: var(--code-comment);
}
.highlight :global(.token.delimiter),
.highlight :global(.token.boolean),
.highlight :global(.token.keyword),
.highlight :global(.token.selector),
.highlight :global(.token.important),
.highlight :global(.token.doctype),
.highlight :global(.token.atrule),
.highlight :global(.token.url) {
color: var(--code-keyword);
}
.highlight :global(.token.tag),
.highlight :global(.token.builtin),
.highlight :global(.token.regex) {
color: var(--code-namespace);
}
.highlight :global(.token.property),
.highlight :global(.token.constant),
.highlight :global(.token.variable),
.highlight :global(.token.attr-value),
.highlight :global(.token.class-name),
.highlight :global(.token.string),
.highlight :global(.token.char) {
color: var(--code-variable);
}
.highlight :global(.token.literal-property),
.highlight :global(.token.attr-name) {
color: var(--code-attribute);
}
.highlight :global(.token.function) {
color: var(--code-literal);
}
.highlight :global(.token.tag .punctuation),
.highlight :global(.token.attr-value .punctuation) {
color: var(--code-punctuation);
}
.highlight :global(.token.inserted) {
background-color: var(--code-addition);
}
.highlight :global(.token.deleted) {
background-color: var(--code-deletion);
}
.highlight :global(.token.url) {
text-decoration: underline;
}
.highlight :global(.token.bold) {
font-weight: bold;
}
.highlight :global(.token.italic) {
font-style: italic;
}

View File

@ -1,9 +1,109 @@
import classNames from "classnames";
import CopyButton from "../CopyButton/CopyButton";
import { styled } from "../../lib/styles/stitches.config";
import type { ComponentProps } from "react";
import styles from "./CodeBlock.module.css";
const Code = styled("code", {
fontSize: "0.925em",
color: "$codeText",
pageBreakInside: "avoid",
backgroundColor: "$codeBackground",
border: "1px solid $kindaLight",
borderRadius: "$rounded",
export type CodeBlockProps = JSX.IntrinsicElements["code"] & {
// light-dark theme switch fading
transition: "background 0.25s ease, border 0.25s ease",
variants: {
highlight: {
// the following sub-classes MUST be global -- the prism rehype plugin isn't aware of this file
true: {
// leave room for clipboard button to the right of the first line
".code-line:first-of-type": {
marginRight: "3em",
},
".line-number::before": {
display: "inline-block",
width: "1.5em",
marginRight: "1.5em",
textAlign: "right",
color: "$codeComment",
content: "attr(line)", // added to spans by prism
userSelect: "none",
},
".token": {
"&.comment, &.prolog, &.cdata": {
color: "$codeComment",
},
"&.delimiter, &.boolean, &.keyword, &.selector, &.important, &.doctype, &.atrule, &.url": {
color: "$codeKeyword",
},
"&.tag, &.builtin, &.regex": {
color: "$codeNamespace",
},
"&.property, &.constant, &.variable, &.attr-value, &.class-name, &.string, &.char": {
color: "$codeVariable",
},
"&.literal-property, &.attr-name": {
color: "$codeAttribute",
},
"&.function": {
color: "$codeLiteral",
},
"&.tag .punctuation, &.attr-value .punctuation": {
color: "$codePunctuation",
},
"&.inserted": {
color: "$codeAddition",
},
"&.deleted": {
color: "$codeDeletion",
},
"&.url": { textDecoration: "underline" },
"&.bold": { fontWeight: "bold" },
"&.italic": { fontStyle: "italic" },
},
},
},
},
});
const InlineCode = styled(Code, {
padding: "0.2em 0.3em",
});
const Block = styled("div", {
position: "relative",
width: "100%",
margin: "1em auto",
[`& ${Code}`]: {
display: "block",
overflowX: "auto",
padding: "1em",
tabSize: 2,
},
});
const CornerCopyButton = styled(CopyButton, {
position: "absolute",
top: 0,
right: 0,
padding: "0.65em",
color: "$mediumDark",
backgroundColor: "$backgroundInner",
border: "1px solid $kindaLight",
borderTopRightRadius: "$radii$rounded",
borderEndStartRadius: "$radii$rounded",
// light-dark theme switch fading
transition: "background 0.25s ease, border 0.25s ease",
"&:hover": {
color: "$link",
},
});
export type CodeBlockProps = ComponentProps<typeof Code> & {
forceBlock?: boolean;
};
@ -15,26 +115,19 @@ const CodeBlock = ({ forceBlock, className, children, ...rest }: CodeBlockProps)
// 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 (
<div className={styles.block}>
<CopyButton source={children} className={styles.copy_btn} />
<code
className={classNames(
styles.code,
prismEnabled && styles.highlight,
className?.replace("code-highlight", "").trim()
)}
{...rest}
>
<Block>
<CornerCopyButton source={children} />
<Code highlight={prismEnabled} className={className?.replace("code-highlight", "").trim()} {...rest}>
{children}
</code>
</div>
</Code>
</Block>
);
} else {
// inline code in paragraphs, headings, etc. (not highlighted)
return (
<code className={classNames(styles.code, styles.inline, className)} {...rest}>
<InlineCode className={className} {...rest}>
{children}
</code>
</InlineCode>
);
}
};

View File

@ -1,45 +0,0 @@
import { memo } from "react";
import css from "styled-jsx/css";
import classNames from "classnames";
import Link, { CustomLinkProps } from "../Link/Link";
export type ColorfulLinkProps = CustomLinkProps & {
lightColor: string;
darkColor: string;
};
// spits out alpha'd version of given color in rgba() format within a linear-gradient (that's not really a gradient)
const getLinearGradient = (hex: string, alpha = 0.4) => {
// hex -> rgb, pulled from https://github.com/sindresorhus/hex-rgb/blob/main/index.js#L29
const number = Number.parseInt(hex.replace(/^#/, ""), 16);
const red = number >> 16;
const green = (number >> 8) & 255;
const blue = number & 255;
const rgbaString = `rgba(${red},${green},${blue},${alpha})`;
return `linear-gradient(${rgbaString},${rgbaString})`;
};
const ColorfulLink = ({ lightColor, darkColor, className, ...rest }: ColorfulLinkProps) => {
const { className: underlineClassName, styles: underlineStyles } = css.resolve`
a {
color: ${lightColor};
background-image: ${getLinearGradient(lightColor)};
}
:global([data-theme="dark"]) a {
color: ${darkColor};
background-image: ${getLinearGradient(darkColor)};
}
`;
return (
<>
<Link className={classNames(underlineClassName, className)} {...rest} />
{underlineStyles}
</>
);
};
export default memo(ColorfulLink);

View File

@ -1,6 +0,0 @@
.wrapper :global(.giscus) {
margin-top: 2em;
padding-top: 2em;
border-top: 2px solid var(--light);
min-height: 350px;
}

View File

@ -1,21 +1,29 @@
import { memo } from "react";
import { useTheme } from "next-themes";
import classNames from "classnames";
import { Giscus } from "@giscus/react";
import { styled } from "../../lib/styles/stitches.config";
import { giscusConfig } from "../../lib/config";
import type { ComponentProps } from "react";
import type { GiscusProps } from "@giscus/react";
import styles from "./Comments.module.css";
const Wrapper = styled("div", {
".giscus": {
marginTop: "2em",
paddingTop: "2em",
borderTop: "2px solid $light",
minHeight: "350px",
},
});
export type CommentsProps = JSX.IntrinsicElements["div"] & {
export type CommentsProps = ComponentProps<typeof Wrapper> & {
title: string;
};
const Comments = ({ title, className, ...rest }: CommentsProps) => {
const Comments = ({ title, ...rest }: CommentsProps) => {
const { resolvedTheme } = useTheme();
return (
<div className={classNames(styles.wrapper, className)} {...rest}>
<Wrapper {...rest}>
<Giscus
{...(giscusConfig as GiscusProps)}
term={title}
@ -24,7 +32,7 @@ const Comments = ({ title, className, ...rest }: CommentsProps) => {
emitMetadata="0"
theme={resolvedTheme === "dark" ? "dark" : "light"}
/>
</div>
</Wrapper>
);
};

View File

@ -1,94 +0,0 @@
.input {
width: 100%;
padding: 0.8em;
margin: 0.6em 0;
border: 2px solid;
border-radius: var(--rounded-edge-radius);
color: var(--text);
background-color: var(--super-duper-light);
border-color: var(--light);
/* light-dark theme switch fading */
transition: background 0.25s ease;
}
.input:focus {
outline: none;
border-color: var(--link);
}
.input.missing {
border-color: var(--error);
}
.textarea {
margin-bottom: 0;
line-height: 1.5;
min-height: 10em;
resize: vertical;
}
.markdown_tip {
font-size: 0.825em;
line-height: 1.75;
}
.captcha {
margin: 1em 0;
}
.action_row {
display: flex;
align-items: center;
min-height: 3.75em;
}
.btn_submit {
flex-shrink: 0;
height: 3.25em;
padding: 1em 1.25em;
margin-right: 1.5em;
border: 0;
border-radius: var(--rounded-edge-radius);
cursor: pointer;
user-select: none;
font-weight: 500;
color: var(--text);
background-color: var(--kinda-light);
}
.btn_submit:hover {
color: var(--super-duper-light);
background-color: var(--link);
}
.send_icon {
width: 1.2em;
height: 1.2em;
vertical-align: -0.2em;
margin-right: 0.4em;
}
.result_icon {
width: 1.3em;
height: 1.3em;
vertical-align: -0.3em;
}
.result_success,
.result_error {
font-weight: 600;
line-height: 1.5;
}
.result_success {
color: var(--success);
}
.result_error {
color: var(--error);
}
.hidden {
display: none !important;
}

View File

@ -1,13 +1,126 @@
import { useState } from "react";
import classNames from "classnames";
import { Formik, Form, Field } from "formik";
import TextareaAutosize from "react-textarea-autosize";
import Link from "../Link/Link";
import Captcha from "../Captcha/Captcha";
import { SendIcon, CheckOcticon, XOcticon } from "../Icons";
import { styled, css } from "../../lib/styles/stitches.config";
import type { FormikHelpers } from "formik";
import styles from "./ContactForm.module.css";
// CSS applied to both `<input />` and `<textarea />`
const InputStyles = css({
width: "100%",
padding: "0.8em",
margin: "0.6em 0",
border: "2px solid",
borderRadius: "$rounded",
color: "$text",
backgroundColor: "$superDuperLight",
borderColor: "$light",
// light-dark theme switch fading
transition: "background 0.25s ease",
"&:focus": {
outline: "none",
borderColor: "$link",
},
variants: {
missing: {
true: {
borderColor: "$error",
},
},
},
});
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 HCaptcha = 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: "$rounded",
cursor: "pointer",
userSelect: "none",
fontWeight: 500,
color: "$text",
backgroundColor: "$kindaLight",
"&:hover": {
color: "$superDuperLight",
backgroundColor: "$link",
},
variants: {
hidden: {
true: {
display: "none",
},
},
},
});
const SubmitIcon = styled(SendIcon, {
width: "1.2em",
height: "1.2em",
verticalAlign: "-0.2em",
marginRight: "0.4em",
});
const Result = styled("div", {
fontWeight: 600,
lineHeight: 1.5,
variants: {
status: {
success: {
color: "$success",
},
error: {
color: "$error",
},
},
hidden: {
true: {
display: "none",
},
},
},
});
const ResultIcon = styled("svg", {
width: "1.3em",
height: "1.3em",
verticalAlign: "-0.3em",
fill: "currentColor",
});
type Values = {
name: string;
@ -100,11 +213,11 @@ const ContactForm = ({ className }: ContactFormProps) => {
<Form className={className} name="contact">
<Field name="name">
{({ field, meta }) => (
<input
<Input
type="text"
className={classNames(styles.input, meta.error && meta.touched && styles.missing)}
placeholder="Name"
disabled={success}
missing={meta.error && meta.touched}
{...field}
/>
)}
@ -112,12 +225,12 @@ const ContactForm = ({ className }: ContactFormProps) => {
<Field name="email">
{({ field, meta }) => (
<input
<Input
type="email"
inputMode="email"
className={classNames(styles.input, meta.error && meta.touched && styles.missing)}
placeholder="Email"
disabled={success}
missing={meta.error && meta.touched}
{...field}
/>
)}
@ -125,17 +238,17 @@ const ContactForm = ({ className }: ContactFormProps) => {
<Field name="message">
{({ field, meta }) => (
<TextareaAutosize
className={classNames(styles.input, styles.textarea, meta.error && meta.touched && styles.missing)}
<TextArea
placeholder="Write something..."
minRows={5}
disabled={success}
missing={meta.error && meta.touched}
{...field}
/>
)}
</Field>
<div className={styles.markdown_tip}>
<MarkdownTip>
Basic{" "}
<Link href="https://commonmark.org/help/" title="Markdown reference sheet" style={{ fontWeight: 600 }}>
Markdown syntax
@ -145,45 +258,32 @@ const ContactForm = ({ className }: ContactFormProps) => {
links
</Link>
](https://jarv.is), and <code>`code`</code>.
</div>
</MarkdownTip>
<div className={styles.captcha}>
<Captcha onVerify={(token) => setFieldValue("h-captcha-response", token)} />
</div>
<HCaptcha onVerify={(token) => setFieldValue("h-captcha-response", token)} />
<div className={styles.action_row}>
<button
className={classNames(styles.btn_submit, success && styles.hidden)}
<ActionRow>
<SubmitButton
type="submit"
title="Send Message"
aria-label="Send Message"
onClick={() => setSubmitted(true)}
disabled={isSubmitting}
hidden={success}
>
{isSubmitting ? (
<span>Sending...</span>
) : (
<>
<SendIcon className={classNames(styles.send_icon)} /> <span>Send</span>
<SubmitIcon /> <span>Send</span>
</>
)}
</button>
</SubmitButton>
<span
className={classNames(
success && styles.result_success,
!success && styles.result_error,
(!submitted || !feedback || isSubmitting) && styles.hidden
)}
>
{success ? (
<CheckOcticon className={styles.result_icon} fill="CurrentColor" />
) : (
<XOcticon className={styles.result_icon} fill="CurrentColor" />
)}{" "}
{feedback}
</span>
</div>
<Result status={success ? "success" : "error"} hidden={!submitted || !feedback || isSubmitting}>
<ResultIcon as={success ? CheckOcticon : XOcticon} /> {feedback}
</Result>
</ActionRow>
</Form>
)}
</Formik>

View File

@ -1,12 +0,0 @@
.content {
font-size: 0.9em;
line-height: 1.7;
color: var(--text);
}
@media screen and (max-width: 768px) {
.content {
font-size: 0.925em;
line-height: 1.85;
}
}

View File

@ -1,11 +1,14 @@
import classNames from "classnames";
import { styled } from "../../lib/styles/stitches.config";
import styles from "./Content.module.css";
const Content = styled("div", {
fontSize: "0.9em",
lineHeight: 1.7,
color: "$text",
export type ContentProps = JSX.IntrinsicElements["div"];
const Content = ({ className, ...rest }: ContentProps) => (
<div className={classNames(styles.content, className)} {...rest} />
);
"@mobile": {
fontSize: "0.925em",
lineHeight: 1.85,
},
});
export default Content;

View File

@ -1,14 +0,0 @@
.button {
line-height: 1;
cursor: pointer;
}
.button.success {
color: var(--success) !important;
}
.icon {
width: 1.25em;
height: 1.25em;
vertical-align: -0.3em;
}

View File

@ -1,11 +1,29 @@
import { forwardRef, useState, useEffect } from "react";
import classNames from "classnames";
import copy from "copy-to-clipboard";
import innerText from "react-innertext";
import { ClipboardOcticon, CheckOcticon } from "../Icons";
import { styled } from "../../lib/styles/stitches.config";
import type { ReactNode, Ref } from "react";
import styles from "./CopyButton.module.css";
const Button = styled("button", {
lineHeight: 1,
cursor: "pointer",
variants: {
success: {
true: {
color: "$success !important",
},
},
},
});
const Icon = styled("svg", {
width: "1.25em",
height: "1.25em",
verticalAlign: "-0.3em",
fill: "currentColor",
});
export type CopyButtonProps = {
source: ReactNode;
@ -45,20 +63,17 @@ const CopyButton = forwardRef(function CopyButton(
}, [timeout, copied]);
return (
<button
className={classNames(styles.button, copied && styles.success, className)}
<Button
className={className}
title="Copy to clipboard"
aria-label="Copy to clipboard"
onClick={handleCopy}
disabled={!!copied}
success={copied}
ref={ref}
>
{copied ? (
<CheckOcticon className={styles.icon} fill="currentColor" />
) : (
<ClipboardOcticon className={styles.icon} fill="currentColor" />
)}
</button>
<Icon as={copied ? CheckOcticon : ClipboardOcticon} />
</Button>
);
});

View File

@ -1,16 +0,0 @@
.figure {
margin: 1em auto;
text-align: center;
}
.caption {
font-size: 0.9em;
line-height: 1.5;
color: var(--medium);
margin-top: -0.4em;
}
/* some figcaptions contain paragraphs, some don't, so reset all of them */
.caption p {
margin: 0 !important;
}

View File

@ -1,10 +1,25 @@
import Image from "../Image/Image";
import innerText from "react-innertext";
import classNames from "classnames";
import { styled } from "../../lib/styles/stitches.config";
import type { PropsWithChildren } from "react";
import type { ImageProps as NextImageProps } from "next/image";
import styles from "./Figure.module.css";
const Wrapper = styled("figure", {
margin: "1em auto",
textAlign: "center",
});
const Caption = styled("figcaption", {
fontSize: "0.9em",
lineHeight: 1.5,
color: "$medium",
marginTop: "-0.4em",
/* some figcaptions contain paragraphs, some don't, so reset all of them */
"& p": {
margin: "0 !important",
},
});
export type FigureProps = Omit<NextImageProps, "alt"> &
PropsWithChildren<{
@ -13,10 +28,10 @@ export type FigureProps = Omit<NextImageProps, "alt"> &
const Figure = ({ children, alt, className, ...imageProps }: FigureProps) => {
return (
<figure className={classNames(styles.figure, className)}>
<Wrapper className={className}>
<Image alt={alt || innerText(children)} {...imageProps} />
<figcaption className={styles.caption}>{children}</figcaption>
</figure>
<Caption>{children}</Caption>
</Wrapper>
);
};

View File

@ -1,88 +0,0 @@
.footer {
width: 100%;
padding: 1.25em 1.5em;
border-top: 1px solid var(--kinda-light);
background-color: var(--background-outer);
color: var(--medium-dark);
/* light-dark theme switch fading */
transition: color 0.25s ease, background 0.25s ease, border 0.25s ease;
}
.link {
color: inherit;
text-decoration: none;
}
.row {
display: flex;
width: 100%;
max-width: 865px;
margin: 0 auto;
justify-content: space-between;
font-size: 0.85em;
line-height: 2.3;
}
.icon {
width: 1.25em;
height: 1.25em;
vertical-align: -0.25em;
margin: 0 0.075em;
}
.nextjs:hover {
color: var(--medium);
}
.view_source {
padding-bottom: 2px;
border-bottom: 1px solid;
border-color: var(--light);
}
.view_source:hover {
border-color: var(--kinda-light);
}
.heart {
display: inline-block;
animation: beat 10s infinite; /* 6 bpm, call 911 if you see this please. */
animation-delay: 7.5s; /* offset from wave animation */
will-change: transform;
}
@media screen and (max-width: 768px) {
.footer {
padding: 1em 1.25em;
}
/* stack columns on left instead of flexboxing across */
.row {
font-size: 0.8em;
display: block;
}
}
@keyframes beat {
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);
}
}

View File

@ -1,60 +1,128 @@
import { memo } from "react";
import Link from "next/link";
import classNames from "classnames";
import NextLink from "next/link";
import { HeartIcon, NextjsLogo } from "../Icons";
import { keyframes, styled } from "../../lib/styles/stitches.config";
import * as config from "../../lib/config";
import type { ComponentProps } from "react";
import styles from "./Footer.module.css";
const Wrapper = styled("footer", {
width: "100%",
padding: "1.25em 1.5em",
borderTop: "1px solid $kindaLight",
backgroundColor: "$backgroundOuter",
color: "$mediumDark",
transition: "color 0.25s ease, background 0.25s ease, border 0.25s ease",
export type FooterProps = JSX.IntrinsicElements["footer"];
"@mobile": {
padding: "1em 1.25em",
},
});
const Footer = ({ className, ...rest }: FooterProps) => (
<footer className={classNames(styles.footer, className)} {...rest}>
<div className={styles.row}>
<div className={styles.license}>
const Row = styled("div", {
display: "flex",
width: "100%",
maxWidth: "865px",
margin: "0 auto",
justifyContent: "space-between",
fontSize: "0.85em",
lineHeight: 2.3,
// stack columns on left instead of flexboxing across
"@mobile": {
fontSize: "0.8em",
display: "block",
},
});
const Link = styled("a", {
color: "$mediumDark",
textDecoration: "none",
});
const NextjsLink = styled(Link, {
"&:hover": {
color: "$medium",
},
});
const ViewSourceLink = styled(Link, {
paddingBottom: "2px",
borderBottom: "1px solid",
borderColor: "$light",
"&:hover": {
borderColor: "$kindaLight",
},
});
const beat = 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)" },
});
const Heart = styled("span", {
display: "inline-block",
animation: `${beat} 10s infinite`,
animationDelay: "7.5s",
willChange: "transform",
});
const Icon = styled("svg", {
width: "1.25em",
height: "1.25em",
verticalAlign: "-0.25em",
margin: "0 0.075em",
});
export type FooterProps = ComponentProps<typeof Wrapper>;
const Footer = ({ ...rest }: FooterProps) => (
<Wrapper {...rest}>
<Row>
<div>
Content{" "}
<Link href="/license/" prefetch={false}>
<a className={styles.link} title="Creative Commons Attribution 4.0 International">
licensed under CC-BY-4.0
</a>
</Link>
<NextLink href="/license/" prefetch={false} passHref={true}>
<Link title="Creative Commons Attribution 4.0 International">licensed under CC-BY-4.0</Link>
</NextLink>
,{" "}
<Link href="/previously/" prefetch={false}>
<a className={styles.link} title="Previously on...">
2001
</a>
</Link>{" "}
<NextLink href="/previously/" prefetch={false} passHref={true}>
<Link title="Previously on...">2001</Link>
</NextLink>{" "}
{new Date().getFullYear()}.
</div>
<div className={styles.powered_by}>
<div>
Made with{" "}
<span className={styles.heart} title="Love">
<HeartIcon className={styles.icon} />
</span>{" "}
<Heart title="Love">
<Icon as={HeartIcon} />
</Heart>{" "}
and{" "}
<a
className={classNames(styles.link, styles.nextjs)}
<NextjsLink
href="https://nextjs.org/"
title="Powered by Next.js"
aria-label="Next.js"
target="_blank"
rel="noopener noreferrer"
>
<NextjsLogo className={styles.icon} fill="currentColor" />
</a>
<Icon as={NextjsLogo} fill="currentColor" />
</NextjsLink>
.{" "}
<a
className={classNames(styles.link, styles.view_source)}
<ViewSourceLink
href={`https://github.com/${config.githubRepo}`}
title="View Source on GitHub"
target="_blank"
rel="noopener noreferrer"
>
View source.
</a>
</ViewSourceLink>
</div>
</div>
</footer>
</Row>
</Wrapper>
);
export default memo(Footer);

View File

@ -1,43 +0,0 @@
.header {
width: 100%;
height: 4.5em;
padding: 0.7em 1.5em;
border-bottom: 1px solid var(--kinda-light);
background-color: var(--background-header);
/* light-dark theme switch fading */
transition: color 0.25s ease, background 0.25s ease, border 0.25s ease;
}
.sticky {
position: sticky;
top: 0;
backdrop-filter: saturate(180%) blur(5px);
z-index: 1000;
}
.nav {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
max-width: 865px;
margin: 0 auto;
}
@media screen and (max-width: 768px) {
.header {
padding: 0.75em 1.25em;
height: 5.9em;
}
.menu {
max-width: 325px;
}
}
@media screen and (max-width: 380px) {
.menu {
max-width: 225px;
}
}

View File

@ -1,21 +1,68 @@
import { memo } from "react";
import classNames from "classnames";
import Selfie from "../Selfie/Selfie";
import Menu from "../Menu/Menu";
import { styled } from "../../lib/styles/stitches.config";
import type { ComponentProps } from "react";
import styles from "./Header.module.css";
const Wrapper = styled("header", {
width: "100%",
height: "4.5em",
padding: "0.7em 1.5em",
borderBottom: "1px solid $kindaLight",
backgroundColor: "$backgroundHeader",
export type HeaderProps = JSX.IntrinsicElements["header"] & {
// light-dark theme switch fading
transition: "color 0.25s ease, background 0.25s ease, border 0.25s ease",
"@mobile": {
padding: "0.75em 1.25em",
height: "5.9em",
},
variants: {
sticky: {
true: {
position: "sticky",
top: 0,
// blurry glass-like background effect (except on firefox)
backdropFilter: "saturate(180%) blur(5px)",
zIndex: 1000,
},
},
},
});
const Nav = styled("nav", {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
width: "100%",
maxWidth: "865px",
margin: "0 auto",
});
const ResponsiveMenu = styled(Menu, {
"@mobile": {
maxWidth: "325px",
},
"@superNarrow": {
maxWidth: "225px",
},
});
export type HeaderProps = ComponentProps<typeof Wrapper> & {
sticky?: boolean;
};
const Header = ({ sticky, className, ...rest }: HeaderProps) => (
<header className={classNames(styles.header, sticky && styles.sticky, className)} {...rest}>
<nav className={styles.nav}>
<Selfie className={styles.selfie} />
<Menu className={styles.menu} />
</nav>
</header>
const Header = ({ sticky, ...rest }: HeaderProps) => (
<Wrapper sticky={sticky} {...rest}>
<Nav>
<Selfie />
<ResponsiveMenu />
</Nav>
</Wrapper>
);
export default memo(Header);

View File

@ -1,48 +0,0 @@
.heading {
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;
}
/* special bottom border for <h2>s */
.h2 {
padding-bottom: 0.25em;
border-bottom: 1px solid var(--kinda-light);
}
/* sub-heading anchor styles */
.anchor {
margin: 0 0.25em;
padding: 0 0.25em;
color: var(--medium-light);
font-weight: 300;
text-decoration: none;
opacity: 0; /* overridden on hover below (except on small screens) */
}
.anchor::before {
content: "\0023"; /* pound sign `#`, done here to keep content DOM cleaner */
}
.anchor:hover {
color: var(--link);
}
/* make anchor link show up on hover over its corresponding heading */
.heading:hover .anchor {
opacity: 1;
}
@media screen and (max-width: 768px) {
.heading {
scroll-margin-top: 6.5rem;
}
/* don't require hover to show anchor link on small (likely touch) screens */
.anchor {
opacity: 1;
}
}

View File

@ -1,29 +1,71 @@
import classNames from "classnames";
import innerText from "react-innertext";
import type { HTMLAttributes } from "react";
import { styled } from "../../lib/styles/stitches.config";
import type { ComponentProps } from "react";
import styles from "./Heading.module.css";
const Anchor = styled("a", {
margin: "0 0.25em",
padding: "0 0.25em",
color: "$mediumLight",
fontWeight: 300,
textDecoration: "none",
opacity: 0, // overridden on hover below (except on small screens)
export type HeadingProps = HTMLAttributes<HTMLHeadingElement> & {
"&::before": {
// pound sign `#`, done here to keep content DOM cleaner
content: "\\0023",
},
"&:hover": {
color: "$link",
},
// don't require hover to show anchor link on small (likely touch) screens
"@mobile": {
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",
"@mobile": {
scrollMarginTop: "6.5rem",
},
[`&:hover ${Anchor}`]: {
opacity: 1,
},
variants: {
underline: {
true: {
paddingBottom: "0.25em",
borderBottom: "1px solid $kindaLight",
},
},
},
});
export type HeadingProps = ComponentProps<typeof H> & {
as: "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
};
const Heading = ({ as: Component, id, className, children, ...rest }: HeadingProps) => {
const Heading = ({ as, id, children, ...rest }: HeadingProps) => {
return (
<Component className={classNames(styles.heading, styles[Component], className)} id={id} {...rest}>
<H as={as} underline={as === "h2"} id={id} {...rest}>
{children}
{/* add anchor link to H2s and H3s. ID is already generated by rehype-slug. `#` character inserted via CSS. */}
{id && (Component === "h2" || Component === "h3") && (
<a
className={styles.anchor}
href={`#${id}`}
title={`Jump to "${innerText(children)}"`}
tabIndex={-1}
aria-hidden={true}
/>
{id && (as === "h2" || as === "h3") && (
<Anchor href={`#${id}`} title={`Jump to "${innerText(children)}"`} tabIndex={-1} aria-hidden={true} />
)}
</Component>
</H>
);
};

View File

@ -1,6 +0,0 @@
.hr {
margin: 1.5em auto;
height: 0.175em;
border: 0;
background-color: var(--light);
}

View File

@ -1,11 +1,10 @@
import classNames from "classnames";
import { styled } from "../../lib/styles/stitches.config";
import styles from "./HorizontalRule.module.css";
export type HorizontalRuleProps = JSX.IntrinsicElements["hr"];
const HorizontalRule = ({ className, ...rest }: HorizontalRuleProps) => (
<hr className={classNames(styles.hr, className)} {...rest} />
);
const HorizontalRule = styled("hr", {
margin: "1.5em auto",
height: "0.175em",
border: 0,
backgroundColor: "$light",
});
export default HorizontalRule;

View File

@ -1,7 +0,0 @@
.frame {
width: 100%;
display: block;
margin: 1em auto;
border: 2px solid var(--kinda-light);
border-radius: var(--rounded-edge-radius);
}

View File

@ -1,8 +1,15 @@
import classNames from "classnames";
import { styled } from "../../lib/styles/stitches.config";
import type { ComponentProps } from "react";
import styles from "./IFrame.module.css";
const RoundedIFrame = styled("iframe", {
width: "100%",
display: "block",
margin: "1em auto",
border: "2px solid $kindaLight",
borderRadius: "$rounded",
});
export type IFrameProps = JSX.IntrinsicElements["iframe"] & {
export type IFrameProps = ComponentProps<typeof RoundedIFrame> & {
src: string;
height: number;
width?: number; // defaults to 100%
@ -10,17 +17,16 @@ export type IFrameProps = JSX.IntrinsicElements["iframe"] & {
noScroll?: boolean;
};
const IFrame = ({ src, title, height, width, allowScripts, noScroll, className, ...rest }: IFrameProps) => (
<iframe
className={classNames(styles.frame, className)}
const IFrame = ({ src, title, height, width, allowScripts, noScroll, ...rest }: IFrameProps) => (
<RoundedIFrame
src={src}
title={title}
sandbox={allowScripts ? "allow-same-origin allow-scripts allow-popups" : undefined}
scrolling={noScroll ? "no" : undefined}
loading="lazy"
style={{
css={{
height: `${height}px`,
maxWidth: width ? `${width}px` : undefined,
maxWidth: width ? `${width}px` : null,
}}
{...rest}
/>

View File

@ -1,11 +0,0 @@
.wrapper {
line-height: 0;
/* default to centering all images */
margin: 1em auto;
text-align: center;
}
.image {
border-radius: var(--rounded-edge-radius);
}

View File

@ -1,8 +1,19 @@
import NextImage from "next/image";
import classNames from "classnames";
import { styled } from "../../lib/styles/stitches.config";
import type { ComponentProps } from "react";
import type { ImageProps as NextImageProps, StaticImageData } from "next/image";
import styles from "./Image.module.css";
const Wrapper = styled("div", {
lineHeight: 0,
// default to centering all images
margin: "1em auto",
textAlign: "center",
});
const RoundedImage = styled(NextImage, {
borderRadius: "$rounded",
});
const CustomImage = ({
src,
@ -15,7 +26,7 @@ const CustomImage = ({
priority,
className,
...rest
}: NextImageProps) => {
}: NextImageProps & ComponentProps<typeof RoundedImage>) => {
// passed directly into next/image: https://nextjs.org/docs/api-reference/next/image
const imageProps: Partial<NextImageProps> = {
width: typeof width === "string" ? Number.parseInt(width) : width,
@ -40,10 +51,10 @@ const CustomImage = ({
}
return (
<div className={classNames(styles.wrapper, className)}>
<Wrapper className={className}>
{/* @ts-ignore */}
<NextImage className={styles.image} {...imageProps} {...rest} />
</div>
<RoundedImage {...imageProps} {...rest} />
</Wrapper>
);
};

View File

@ -1,27 +0,0 @@
.flex {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.default {
width: 100%;
padding: 1.5em;
}
.container {
max-width: 865px;
margin: 0 auto;
display: block;
}
/* footer needs to fill the remaining vertical screen space. doing it here to keep flex stuff together. */
.footer {
flex: 1;
}
@media screen and (max-width: 768px) {
.main {
padding: 1.25em;
}
}

View File

@ -1,41 +1,66 @@
import Head from "next/head";
import { useTheme } from "next-themes";
import classNames from "classnames";
import Header from "../Header/Header";
import Footer from "../Footer/Footer";
import themes from "../../lib/config/themes";
import { styled, theme, darkTheme } from "../../lib/styles/stitches.config";
import type { ComponentProps } from "react";
import styles from "./Layout.module.css";
const Flex = styled("div", {
display: "flex",
flexDirection: "column",
minHeight: "100vh",
});
export type LayoutProps = JSX.IntrinsicElements["div"] & {
const Default = styled("main", {
width: "100%",
padding: "1.5em",
});
const Container = styled("div", {
maxWidth: "865px",
margin: "0 auto",
display: "block",
});
// 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 = ComponentProps<typeof Flex> & {
container?: boolean; // pass false to disable default `<main>` container styles with padding, etc.
stickyHeader?: boolean; // pass false to override default stickiness of header when scrolling
};
const Layout = ({ container = true, stickyHeader = true, className, children, ...rest }: LayoutProps) => {
const Layout = ({ container = true, stickyHeader = true, children, ...rest }: LayoutProps) => {
const { resolvedTheme } = useTheme();
return (
<>
<Head>
{/* dynamically set browser theme color to match the background color; default to light for SSR */}
<meta name="theme-color" content={themes[resolvedTheme || "light"].backgroundOuter} />
<meta
name="theme-color"
content={
resolvedTheme === "dark" ? darkTheme.colors.backgroundOuter.value : theme.colors.backgroundOuter.value
}
/>
</Head>
<div className={classNames(styles.flex, className)} {...rest}>
<Flex {...rest}>
<Header sticky={stickyHeader} />
{/* passing `container={false}` to Layout allows 100% control of the content area on a per-page basis */}
{container ? (
<main className={styles.default}>
<div className={styles.container}>{children}</div>
</main>
<Default>
<Container>{children}</Container>
</Default>
) : (
<>{children}</>
)}
<Footer className={styles.footer} />
</div>
<FlexedFooter />
</Flex>
</>
);
};

View File

@ -1,16 +0,0 @@
.link {
color: var(--link);
background-image: linear-gradient(var(--link-underline), var(--link-underline));
background-position: 0% 100%;
background-repeat: no-repeat;
background-size: 0% var(--link-underline-size);
text-decoration: none;
padding-bottom: 0.2rem;
/* background-size is for hover animation, color & border are for theme fading: */
transition: background-size 0.25s ease-in-out, color 0.25s ease, border 0.25s ease;
}
.link:hover {
background-size: 100% var(--link-underline-size);
}

View File

@ -1,11 +1,28 @@
import NextLink from "next/link";
import classNames from "classnames";
import isAbsoluteUrl from "is-absolute-url";
import { styled } from "../../lib/styles/stitches.config";
import type { ComponentProps } from "react";
import type { LinkProps as NextLinkProps } from "next/link";
import styles from "./Link.module.css";
const FancyLink = styled("a", {
color: "$link",
textDecoration: "none",
transition: "background-size 0.25s ease-in-out, color 0.25s ease, border 0.25s ease",
export type CustomLinkProps = Omit<JSX.IntrinsicElements["a"], "href"> &
backgroundPosition: "0% 100%",
backgroundRepeat: "no-repeat",
backgroundSize: "0% $underline",
paddingBottom: "0.2rem",
// sets psuedo linear-gradient() for cool underline effect
backgroundGradientHack: {},
"&:hover": {
backgroundSize: "100% $underline",
},
});
export type CustomLinkProps = Omit<ComponentProps<typeof FancyLink>, "href"> &
NextLinkProps & {
forceNewWindow?: boolean;
};
@ -17,25 +34,18 @@ const CustomLink = ({
target,
rel,
forceNewWindow,
className,
...rest
}: CustomLinkProps) => {
// this component auto-detects whether or not we should use a normal HTML anchor externally or next/link internally,
// can be overridden with `forceNewWindow={true}`.
if (forceNewWindow || isAbsoluteUrl(href.toString())) {
return (
<a
href={href.toString()}
target={target || "_blank"}
rel={rel || "noopener noreferrer"}
className={classNames(styles.link, className)}
{...rest}
/>
<FancyLink href={href.toString()} target={target || "_blank"} rel={rel || "noopener noreferrer"} {...rest} />
);
} else {
return (
<NextLink href={href} prefetch={prefetch} passHref={passHref}>
<a className={classNames(styles.link, className)} {...rest} />
<FancyLink {...rest} />
</NextLink>
);
}

View File

@ -1,9 +0,0 @@
.unordered,
.ordered {
margin-left: 1.5em;
padding-left: 0;
}
.item {
padding-left: 0.25em;
}

View File

@ -1,15 +1,13 @@
import classNames from "classnames";
import { styled, css } from "../../lib/styles/stitches.config";
import styles from "./List.module.css";
const ListStyles = css({
marginLeft: "1.5em",
paddingLeft: 0,
});
export const UnorderedList = ({ className, ...rest }: JSX.IntrinsicElements["ul"]) => (
<ul className={classNames(styles.unordered, className)} {...rest} />
);
export const OrderedList = ({ className, ...rest }: JSX.IntrinsicElements["ol"]) => (
<ol className={classNames(styles.ordered, className)} {...rest} />
);
export const UnorderedList = styled("ul", ListStyles);
export const OrderedList = styled("ol", ListStyles);
// TODO: this is based on good faith that the children are all `<li>`s...
export const ListItem = ({ className, ...rest }: JSX.IntrinsicElements["li"]) => (
<li className={classNames(styles.item, className)} {...rest} />
);
export const ListItem = styled("li", {
paddingLeft: "0.25em",
});

View File

@ -1,22 +0,0 @@
.wrapper {
display: inline-block;
text-align: center;
}
.box {
display: inline-block;
height: 100%;
animation: loading 1.5s infinite ease-in-out both;
background-color: var(--medium-light);
}
@keyframes loading {
0%,
80%,
100% {
transform: scale(0);
}
40% {
transform: scale(0.6);
}
}

View File

@ -1,7 +1,26 @@
import { memo } from "react";
import classNames from "classnames";
import { styled, keyframes } from "../../lib/styles/stitches.config";
import styles from "./Loading.module.css";
const pulse = keyframes({
"0%, 80%, 100%": {
transform: "scale(0)",
},
"40%": {
transform: "scale(0.6)",
},
});
const Wrapper = styled("div", {
display: "inline-block",
textAlign: "center",
});
const Box = styled("div", {
display: "inline-block",
height: "100%",
animation: `${pulse} 1.5s infinite ease-in-out both`,
backgroundColor: "$mediumLight",
});
export type LoadingProps = {
width: number; // of entire container, in pixels
@ -19,10 +38,9 @@ const Loading = ({ width, boxes = 3, timing = 0.1, className }: 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(
<div
<Box
key={i}
className={styles.box}
style={{
css={{
width: `${width / (boxes + 1)}px`,
animationDelay: `${i * timing}s`,
}}
@ -31,15 +49,15 @@ const Loading = ({ width, boxes = 3, timing = 0.1, className }: LoadingProps) =>
}
return (
<div
className={classNames(styles.wrapper, className)}
<Wrapper
className={className}
style={{
width: `${width}px`,
height: `${width / 2}px`,
}}
>
{divs}
</div>
</Wrapper>
);
};

View File

@ -1,34 +0,0 @@
.menu {
display: inline-flex;
padding: 0;
margin: 0;
}
.item {
list-style: none;
display: inline-flex;
margin-left: 1em;
}
@media screen and (max-width: 768px) {
.menu {
width: 100%;
justify-content: space-between;
margin-left: 1em;
}
.item {
margin-left: 0;
}
}
@media screen and (max-width: 380px) {
.menu {
margin-left: 1.4em;
}
/* the home icon is redundant when space is SUPER tight */
.item:first-of-type {
display: none;
}
}

View File

@ -1,27 +1,57 @@
import { memo } from "react";
import { useRouter } from "next/router";
import classNames from "classnames";
import MenuItem from "../MenuItem/MenuItem";
import { styled } from "../../lib/styles/stitches.config";
import { menuItems } from "../../lib/config/menu";
import type { ComponentProps } from "react";
import styles from "./Menu.module.css";
const Wrapper = styled("ul", {
display: "inline-flex",
padding: 0,
margin: 0,
export type MenuProps = {
className?: string;
};
"@mobile": {
width: "100%",
justifyContent: "space-between",
marginLeft: "1em",
},
const Menu = ({ className }: MenuProps) => {
"@superNarrow": {
marginLeft: "1.4em",
},
});
const Item = styled("li", {
listStyle: "none",
display: "inline-flex",
marginLeft: "1em",
"@mobile": {
marginLeft: 0,
},
"@superNarrow": {
// the home icon is kinda redundant when space is SUPER tight
"&:first-of-type": {
display: "none",
},
},
});
export type MenuProps = ComponentProps<typeof Wrapper>;
const Menu = ({ ...rest }: MenuProps) => {
const router = useRouter();
return (
<ul className={classNames(styles.menu, className)}>
<Wrapper {...rest}>
{menuItems.map((item, index) => (
<li key={index} className={styles.item}>
<Item key={index}>
{/* kinda weird/hacky way to determine if the *first part* of the current path matches this href */}
<MenuItem {...item} current={item.href === `/${router.pathname.split("/")[1]}`} />
</li>
</Item>
))}
</ul>
</Wrapper>
);
};

View File

@ -1,47 +0,0 @@
.link {
display: inline-flex;
align-items: center;
color: var(--medium-dark);
line-height: 1;
text-decoration: none;
padding: 0.6em;
}
.link:hover,
.link.current {
border-bottom: 0.2em solid;
margin-bottom: -0.2em;
}
.link:hover {
border-color: var(--kinda-light);
}
.link.current {
border-color: var(--link-underline);
}
.icon {
width: 1.25em;
height: 1.25em;
vertical-align: -0.3em;
}
.label {
font-size: 0.95em;
font-weight: 500;
margin-top: 0.1em;
margin-left: 0.8em;
}
@media screen and (max-width: 768px) {
.icon {
width: 1.8em;
height: 1.8em;
}
/* hide text next to emojis on mobile */
.label {
display: none;
}
}

View File

@ -1,7 +1,52 @@
import Link from "next/link";
import classNames from "classnames";
import NextLink from "next/link";
import { styled } from "../../lib/styles/stitches.config";
import styles from "./MenuItem.module.css";
const Link = styled("a", {
display: "inline-flex",
alignItems: "center",
color: "$mediumDark",
lineHeight: 1,
textDecoration: "none",
padding: "0.6em",
"&:hover": {
borderBottom: "0.2em solid",
marginBottom: "-0.2em",
borderColor: "$kindaLight",
},
variants: {
current: {
true: {
borderBottom: "0.2em solid",
marginBottom: "-0.2em",
borderColor: "$linkUnderline !important",
},
},
},
});
const Label = styled("span", {
fontSize: "0.95em",
fontWeight: 500,
marginTop: "0.1em",
marginLeft: "0.8em",
"@mobile": {
display: "none",
},
});
const Icon = styled("svg", {
width: "1.25em",
height: "1.25em",
verticalAlign: "-0.3em",
"@mobile": {
width: "1.8em",
height: "1.8em",
},
});
export type MenuItemProps = {
href?: string;
@ -14,19 +59,21 @@ export type MenuItemProps = {
icon: any;
};
const MenuItem = ({ icon: Icon, href, text, current, className }: MenuItemProps) => {
const MenuItem = ({ icon: ItemIcon, href, text, current, className }: MenuItemProps) => {
const linkContent = (
<>
<Icon className={classNames(styles.icon, className)} /> {text && <span className={styles.label}>{text}</span>}
<Icon as={ItemIcon} /> {text && <Label>{text}</Label>}
</>
);
// allow both navigational links and/or other interactive react components (e.g. the theme toggle)
if (href) {
return (
<Link href={href} prefetch={false}>
<a className={classNames(styles.link, current && styles.current, className)}>{linkContent}</a>
</Link>
<NextLink href={href} prefetch={false} passHref={true}>
<Link className={className} current={current}>
{linkContent}
</Link>
</NextLink>
);
} else {
return linkContent;

View File

@ -1,55 +0,0 @@
.meta {
display: inline-flex;
flex-wrap: wrap;
font-size: 0.825em;
line-height: 2.3;
letter-spacing: 0.04em;
color: var(--medium);
}
.meta_item {
display: inline-flex;
margin-right: 1.6em;
white-space: nowrap;
}
.icon {
width: 1.2em;
height: 1.2em;
vertical-align: -0.2em;
margin-right: 0.6em;
}
.tags {
white-space: normal;
display: inline-flex;
flex-wrap: wrap;
}
.tag {
text-transform: lowercase;
white-space: nowrap;
margin-right: 0.75em;
}
.tag::before {
content: "\0023"; /* cosmetically hashtagify tags */
padding-right: 0.125em;
color: var(--light);
}
.tag:last-of-type {
margin-right: 0;
}
.date_link,
.edit_link {
color: inherit;
text-decoration: none;
}
.views {
/* fix potential layout shift when number of hits loads */
min-width: 7em;
margin-right: 0;
}

View File

@ -1,73 +1,121 @@
import Link from "next/link";
import classNames from "classnames";
import { format } from "date-fns";
import HitCounter from "../HitCounter/HitCounter";
import NoteTitle from "../NoteTitle/NoteTitle";
import { DateIcon, TagIcon, EditIcon, ViewsIcon } from "../Icons";
import { styled } from "../../lib/styles/stitches.config";
import * as config from "../../lib/config";
import type { NoteType } from "../../types";
import styles from "./NoteMeta.module.css";
const Wrapper = styled("div", {
display: "inline-flex",
flexWrap: "wrap",
fontSize: "0.825em",
lineHeight: 2.3,
letterSpacing: "0.04em",
color: "$medium",
});
const MetaItem = styled("div", {
display: "inline-flex",
marginRight: "1.6em",
whiteSpace: "nowrap",
});
const MetaLink = styled("a", {
color: "inherit",
textDecoration: "none",
});
const Icon = styled("svg", {
width: "1.2em",
height: "1.2em",
verticalAlign: "-0.2em",
marginRight: "0.6em",
});
const Tag = styled("span", {
textTransform: "lowercase",
whiteSpace: "nowrap",
marginRight: "0.75em",
"&::before": {
content: "\\0023", // cosmetically hashtagify tags
paddingRight: "0.125em",
color: "$light",
},
"&:last-of-type": {
marginRight: 0,
},
});
export type NoteMetaProps = Pick<NoteType["frontMatter"], "slug" | "date" | "title" | "htmlTitle" | "tags">;
const NoteMeta = ({ slug, date, title, htmlTitle, tags = [] }: NoteMetaProps) => (
<>
<div className={styles.meta}>
<div className={styles.meta_item}>
<Wrapper>
<MetaItem>
<Link
href={{
pathname: "/notes/[slug]/",
query: { slug },
}}
passHref={true}
>
<a className={styles.date_link}>
<MetaLink>
<span>
<DateIcon className={styles.icon} />
<Icon as={DateIcon} />
</span>
<span title={format(new Date(date), "PPppp")}>{format(new Date(date), "MMMM d, yyyy")}</span>
</a>
</MetaLink>
</Link>
</div>
</MetaItem>
{tags.length > 0 && (
<div className={classNames(styles.meta_item, styles.tags)}>
<MetaItem
css={{
whiteSpace: "normal",
display: "inline-flex",
flexWrap: "wrap",
}}
>
<span>
<TagIcon className={styles.icon} />
<Icon as={TagIcon} />
</span>
{tags.map((tag) => (
<span key={tag} className={styles.tag}>
{tag}
</span>
<Tag key={tag}>{tag}</Tag>
))}
</div>
</MetaItem>
)}
<div className={styles.meta_item}>
<a
className={styles.edit_link}
<MetaItem>
<MetaLink
href={`https://github.com/${config.githubRepo}/blob/main/notes/${slug}.mdx`}
target="_blank"
rel="noopener noreferrer"
title={`Edit "${title}" on GitHub`}
>
<span>
<EditIcon className={styles.icon} />
<Icon as={EditIcon} />
</span>
<span>Improve This Post</span>
</a>
</div>
</MetaLink>
</MetaItem>
{/* only count hits on production site */}
{process.env.NEXT_PUBLIC_VERCEL_ENV === "production" && (
<div className={classNames(styles.meta_item, styles.views)}>
<MetaItem
// fix potential layout shift when number of hits loads
css={{ minWidth: "7em", marginRight: 0 }}
>
<span>
<ViewsIcon className={styles.icon} />
<Icon as={ViewsIcon} />
</span>
<HitCounter slug={`notes/${slug}`} />
</div>
</MetaItem>
)}
</div>
</Wrapper>
<NoteTitle slug={slug} htmlTitle={htmlTitle || title} />
</>

View File

@ -1,21 +0,0 @@
.title {
margin: 0.3em 0 0.5em -0.03em;
font-size: 2.1em;
line-height: 1.3;
font-weight: 700;
}
.title code {
margin: 0 0.075em;
}
.link {
color: var(--text);
text-decoration: none;
}
@media screen and (max-width: 768px) {
.title {
font-size: 1.8em;
}
}

View File

@ -1,22 +1,42 @@
import Link from "next/link";
import classNames from "classnames";
import NextLink from "next/link";
import { styled } from "../../lib/styles/stitches.config";
import type { ComponentProps } from "react";
import type { NoteType } from "../../types";
import styles from "./NoteTitle.module.css";
const Title = styled("h1", {
margin: "0.3em 0 0.5em -0.03em",
fontSize: "2.1em",
lineHeight: 1.3,
fontWeight: 700,
export type NoteTitleProps = Pick<NoteType["frontMatter"], "slug" | "htmlTitle"> & JSX.IntrinsicElements["h1"];
"& code": {
margin: "0 0.075em",
},
const NoteTitle = ({ slug, htmlTitle, className, ...rest }: NoteTitleProps) => (
<h1 className={classNames(styles.title, className)} {...rest}>
<Link
"@mobile": {
fontSize: "1.8em",
},
});
const Link = styled("a", {
color: "$text",
textDecoration: "none",
});
export type NoteTitleProps = Pick<NoteType["frontMatter"], "slug" | "htmlTitle"> & ComponentProps<typeof Title>;
const NoteTitle = ({ slug, htmlTitle, ...rest }: NoteTitleProps) => (
<Title {...rest}>
<NextLink
href={{
pathname: "/notes/[slug]/",
query: { slug },
}}
passHref={true}
>
<a className={styles.link} dangerouslySetInnerHTML={{ __html: htmlTitle }} />
</Link>
</h1>
<Link dangerouslySetInnerHTML={{ __html: htmlTitle }} />
</NextLink>
</Title>
);
export default NoteTitle;

View File

@ -1,51 +0,0 @@
.section {
font-size: 1.1em;
line-height: 1.1;
margin: 2.4em 0;
}
.section:first-of-type {
margin-top: 0;
}
.section:last-of-type {
margin-bottom: 0;
}
.year {
font-size: 2.2em;
margin-top: 0;
margin-bottom: 0.5em;
}
.list {
list-style-type: none;
margin: 0;
padding: 0;
}
.row {
display: flex;
line-height: 1.75;
margin-bottom: 1em;
}
.row:last-of-type {
margin-bottom: 0;
}
.date {
width: 5.25em;
flex-shrink: 0;
color: var(--medium);
}
@media screen and (max-width: 768px) {
.section {
margin: 1.8em 0;
}
.year {
font-size: 2em;
}
}

View File

@ -1,8 +1,57 @@
import { format } from "date-fns";
import Link from "../Link/Link";
import { styled } from "../../lib/styles/stitches.config";
import type { NoteType } from "../../types";
import styles from "./NotesList.module.css";
const Section = styled("section", {
fontSize: "1.1em",
lineHeight: 1.1,
margin: "2.4em 0",
"&:first-of-type": {
marginTop: 0,
},
"&:last-of-type": {
marginBottom: 0,
},
"@mobile": {
margin: "1.8em 0",
},
});
const Year = styled("h2", {
fontSize: "2.2em",
marginTop: 0,
marginBottom: "0.5em",
"@mobile": {
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("span", {
width: "5.25em",
flexShrink: 0,
color: "$medium",
});
export type NotesListProps = {
notesByYear: Record<string, NoteType["frontMatter"][]>;
@ -13,12 +62,12 @@ const NotesList = ({ notesByYear }: NotesListProps) => {
Object.entries(notesByYear).forEach(([year, notes]: [string, NoteType["frontMatter"][]]) => {
sections.push(
<section key={year} className={styles.section}>
<h2 className={styles.year}>{year}</h2>
<ul className={styles.list}>
<Section key={year}>
<Year>{year}</Year>
<List>
{notes.map(({ slug, date, htmlTitle }) => (
<li key={slug} className={styles.row}>
<span className={styles.date}>{format(new Date(date), "MMM d")}</span>
<Post key={slug}>
<PostDate>{format(new Date(date), "MMM d")}</PostDate>
<span>
<Link
href={{
@ -28,10 +77,10 @@ const NotesList = ({ notesByYear }: NotesListProps) => {
dangerouslySetInnerHTML={{ __html: htmlTitle }}
/>
</span>
</li>
</Post>
))}
</ul>
</section>
</List>
</Section>
);
});

View File

@ -1,15 +0,0 @@
.link {
margin: 0 0.4em;
color: var(--text);
text-decoration: none;
}
.link:hover {
color: var(--link);
}
.icon {
width: 1.2em;
height: 1.2em;
vertical-align: -0.2em;
}

View File

@ -1,16 +1,32 @@
import classNames from "classnames";
import { OctocatOcticon } from "../Icons";
import { styled } from "../../lib/styles/stitches.config";
import type { ComponentProps } from "react";
import styles from "./OctocatLink.module.css";
const Link = styled("a", {
margin: "0 0.4em",
color: "$text",
textDecoration: "none",
export type OctocatLinkProps = JSX.IntrinsicElements["a"] & {
"&:hover": {
color: "$link",
},
});
const Octocat = styled(OctocatOcticon, {
width: "1.2em",
height: "1.2em",
verticalAlign: "-0.2em",
fill: "currentColor",
});
export type OctocatLinkProps = ComponentProps<typeof Link> & {
repo: string;
};
const OctocatLink = ({ repo, className, ...rest }: OctocatLinkProps) => (
<a className={styles.link} href={`https://github.com/${repo}`} target="_blank" rel="noopener noreferrer" {...rest}>
<OctocatOcticon fill="currentColor" className={classNames(styles.icon, className)} />
</a>
<Link href={`https://github.com/${repo}`} target="_blank" rel="noopener noreferrer" {...rest}>
<Octocat className={className} />
</Link>
);
export default OctocatLink;

View File

@ -1,17 +0,0 @@
.title {
margin-top: 0;
margin-bottom: 0.6em;
font-size: 2em;
text-align: center;
}
.link {
color: var(--text);
text-decoration: none;
}
@media screen and (max-width: 768px) {
.title {
font-size: 1.8em;
}
}

View File

@ -1,22 +1,37 @@
import { useRouter } from "next/router";
import Link from "next/link";
import classNames from "classnames";
import NextLink from "next/link";
import { styled } from "../../lib/styles/stitches.config";
import { baseUrl } from "../../lib/config";
import type { ComponentProps } from "react";
import styles from "./PageTitle.module.css";
const Title = styled("h1", {
marginTop: 0,
marginBottom: "0.6em",
fontSize: "2em",
textAlign: "center",
export type PageTitleProps = JSX.IntrinsicElements["h1"];
"@mobile": {
fontSize: "1.8em",
},
});
const PageTitle = ({ className, children, ...rest }: PageTitleProps) => {
const Link = styled("a", {
color: "$text",
textDecoration: "none",
});
export type PageTitleProps = ComponentProps<typeof Title>;
const PageTitle = ({ children, ...rest }: PageTitleProps) => {
const router = useRouter();
const canonical = `${baseUrl}${router.pathname}/`;
return (
<h1 className={classNames(styles.title, className)} {...rest}>
<Link href={canonical}>
<a className={styles.link}>{children}</a>
</Link>
</h1>
<Title {...rest}>
<NextLink href={canonical} passHref={true}>
<Link>{children}</Link>
</NextLink>
</Title>
);
};

View File

@ -1,62 +0,0 @@
.card {
width: 100%;
padding: 1.2em 1.2em 0.8em 1.2em;
border: 1px solid;
border-radius: var(--rounded-edge-radius);
font-size: 0.85em;
color: var(--medium-dark);
border-color: var(--kinda-light);
/* light-dark theme switch fading */
transition: border 0.25s ease;
}
.name {
font-size: 1.2em;
font-weight: 600;
}
.description {
margin-top: 0.7em;
margin-bottom: 0.5em;
line-height: 1.7;
}
.meta {
display: flex;
flex-wrap: wrap;
align-items: baseline;
}
.meta_item {
margin-right: 1.5em;
font-size: 0.875em;
line-height: 2;
color: var(--medium);
}
.meta_link {
color: inherit;
text-decoration: none;
}
.meta_link:hover {
color: var(--link);
}
.octicon,
.language_color {
width: 16px;
height: 16px;
vertical-align: text-bottom;
margin-right: 0.5em;
}
.language_color {
display: inline-block;
width: 1.15em;
height: 1.15em;
border-radius: 50%;
position: relative;
vertical-align: text-top;
}

View File

@ -1,10 +1,71 @@
import classNames from "classnames";
import { intlFormat, formatDistanceToNowStrict } from "date-fns";
import Link from "../Link/Link";
import { StarOcticon, ForkOcticon } from "../Icons";
import { styled } from "../../lib/styles/stitches.config";
import type { RepositoryType } from "../../types";
import styles from "./RepositoryCard.module.css";
const Wrapper = styled("div", {
width: "100%",
padding: "1.2em 1.2em 0.8em 1.2em",
border: "1px solid $kindaLight",
borderRadius: "$rounded",
fontSize: "0.85em",
color: "$mediumDark",
// light-dark theme switch fading
transition: "border 0.25s ease",
});
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",
fontSize: "0.875em",
lineHeight: 2,
color: "$medium",
});
const MetaLink = styled("a", {
color: "inherit",
textDecoration: "none",
"&:hover": {
color: "$link",
},
});
const MetaIcon = styled("svg", {
width: "16px",
height: "16px",
verticalAlign: "text-bottom",
marginRight: "0.5em",
fill: "currentColor",
});
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 = RepositoryType & {
className?: string;
@ -20,53 +81,48 @@ const RepositoryCard = ({
updatedAt,
className,
}: RepositoryCardProps) => (
<div className={classNames(styles.card, className)}>
<Link className={styles.name} href={url}>
{name}
</Link>
<Wrapper className={className}>
<Name href={url}>{name}</Name>
{description && <p className={styles.description}>{description}</p>}
{description && <Description>{description}</Description>}
<div className={styles.meta}>
<Meta>
{language && (
<div className={styles.meta_item}>
<span className={styles.language_color} style={{ backgroundColor: language.color }} />
<MetaItem>
<LanguageCircle css={{ backgroundColor: language.color }} />
<span>{language.name}</span>
</div>
</MetaItem>
)}
{stars > 0 && (
<div className={styles.meta_item}>
<a
className={styles.meta_link}
<MetaItem>
<MetaLink
href={`${url}/stargazers`}
title={`${stars.toLocaleString("en-US")} ${stars === 1 ? "star" : "stars"}`}
target="_blank"
rel="noopener noreferrer"
>
<StarOcticon fill="currentColor" className={styles.octicon} />
<MetaIcon as={StarOcticon} />
<span>{stars.toLocaleString("en-US")}</span>
</a>
</div>
</MetaLink>
</MetaItem>
)}
{forks > 0 && (
<div className={styles.meta_item}>
<a
className={styles.meta_link}
<MetaItem>
<MetaLink
href={`${url}/network/members`}
title={`${forks.toLocaleString("en-US")} ${forks === 1 ? "fork" : "forks"}`}
target="_blank"
rel="noopener noreferrer"
>
<ForkOcticon fill="currentColor" className={styles.octicon} />
<MetaIcon as={ForkOcticon} />
<span>{forks.toLocaleString("en-US")}</span>
</a>
</div>
</MetaLink>
</MetaItem>
)}
<div
className={styles.meta_item}
<MetaItem
title={intlFormat(
new Date(updatedAt),
{
@ -83,9 +139,9 @@ const RepositoryCard = ({
)}
>
<span>Updated {formatDistanceToNowStrict(new Date(updatedAt), { addSuffix: true })}</span>
</div>
</div>
</div>
</MetaItem>
</Meta>
</Wrapper>
);
export default RepositoryCard;

View File

@ -1,48 +0,0 @@
.link {
display: inline-flex;
align-items: center;
color: var(--medium-dark);
text-decoration: none;
}
.link:hover {
color: var(--link);
}
.selfie {
width: 50px;
height: 50px;
line-height: 0;
padding: 0;
}
.selfie img {
border: 1px solid var(--light) !important;
border-radius: 50%;
}
.name {
margin: 0 0.6em;
font-size: 1.2em;
font-weight: 500;
line-height: 1;
}
@media screen and (max-width: 768px) {
.selfie {
width: 70px;
height: 70px;
}
.name {
display: none;
}
.selfie img {
border-width: 2px !important;
}
.link:hover .selfie img {
border-color: var(--link-underline) !important;
}
}

View File

@ -1,20 +1,66 @@
import { memo } from "react";
import Link from "next/link";
import Image from "next/image";
import classNames from "classnames";
import styles from "./Selfie.module.css";
import NextLink from "next/link";
import NextImage from "next/image";
import { styled } from "../../lib/styles/stitches.config";
import type { ComponentProps } from "react";
import selfieJpg from "../../public/static/images/selfie.jpg";
export type SelfieProps = {
className?: string;
};
const ConstrainImage = styled("div", {
width: "50px",
height: "50px",
lineHeight: 0,
padding: 0,
const Selfie = ({ className }: SelfieProps) => (
<Link href="/">
<a className={classNames(styles.link, className)}>
<div className={styles.selfie}>
"@mobile": {
width: "70px",
height: "70px",
},
});
const Image = styled(NextImage, {
border: "1px solid $light !important",
borderRadius: "50%",
"@mobile": {
borderWidth: "2px !important",
},
});
const Link = styled("a", {
display: "inline-flex",
alignItems: "center",
color: "$mediumDark",
textDecoration: "none",
"&:hover": {
color: "$link",
"@mobile": {
[`${Image}`]: {
borderColor: "$linkUnderline !important",
},
},
},
});
const Name = styled("span", {
margin: "0 0.6em",
fontSize: "1.2em",
fontWeight: 500,
lineHeight: 1,
"@mobile": {
display: "none",
},
});
export type SelfieProps = ComponentProps<typeof Link>;
const Selfie = ({ ...rest }: SelfieProps) => (
<NextLink href="/" passHref={true}>
<Link {...rest}>
<ConstrainImage>
<Image
src={selfieJpg}
alt="Photo of Jake Jarvis"
@ -24,10 +70,10 @@ const Selfie = ({ className }: SelfieProps) => (
layout="intrinsic"
priority
/>
</div>
<span className={styles.name}>Jake Jarvis</span>
</a>
</Link>
</ConstrainImage>
<Name>Jake Jarvis</Name>
</Link>
</NextLink>
);
export default memo(Selfie);

View File

@ -1,28 +0,0 @@
.terminal {
width: 100%;
height: 100%;
padding: 1em;
background-color: #000000;
color: #cccccc;
font-size: 0.925em;
font-weight: 500;
line-height: 2;
white-space: pre-wrap;
user-select: none;
}
.blink {
display: inline-block;
vertical-align: text-bottom;
width: 10px;
border-bottom: 2px solid #cccccc;
animation: blink 1s step-end infinite;
}
@keyframes blink {
50% {
opacity: 0;
}
}

View File

@ -1,17 +1,46 @@
import { forwardRef } from "react";
import classNames from "classnames";
import type { Ref } from "react";
import { keyframes, styled } from "../../lib/styles/stitches.config";
import type { Ref, ComponentProps } from "react";
import styles from "./Terminal.module.css";
const BlackBox = styled("div", {
width: "100%",
height: "100%",
padding: "1.25em",
backgroundColor: "#000000",
color: "#cccccc",
});
export type TerminalProps = JSX.IntrinsicElements["div"];
const Monospace = styled("pre", {
display: "block",
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",
borderBottom: "2px solid #cccccc",
// blink every second for 0.4s
animation: `${keyframes({ "40%": { opacity: 0 } })} 1s step-end infinite`,
});
export type TerminalProps = ComponentProps<typeof BlackBox>;
// a DOS-style terminal box with dynamic text
const Terminal = forwardRef(function Terminal({ className, ...rest }: TerminalProps, ref: Ref<HTMLSpanElement>) {
const Terminal = forwardRef(function Terminal({ ...rest }: TerminalProps, ref: Ref<HTMLSpanElement>) {
return (
<div className={classNames("monospace", className, styles.terminal)} {...rest}>
<span ref={ref} /> <span className={styles.blink} />
</div>
<BlackBox {...rest}>
<Monospace>
<span ref={ref} /> <Underscore />
</Monospace>
</BlackBox>
);
});

View File

@ -1,12 +0,0 @@
.button {
border: 0;
padding: 0.6em;
margin-right: -0.6em;
background: none;
cursor: pointer;
color: var(--medium-dark);
}
.button:hover {
color: var(--warning);
}

View File

@ -1,8 +1,20 @@
import { useEffect, useState, memo } from "react";
import { useTheme } from "next-themes";
import { styled } from "../../lib/styles/stitches.config";
import { SunIcon, MoonIcon } from "../Icons";
import styles from "./ThemeToggle.module.css";
const Button = styled("button", {
border: 0,
padding: "0.6em",
marginRight: "-0.6em",
background: "none",
cursor: "pointer",
color: "$mediumDark",
"&:hover": {
color: "$warning",
},
});
export type ThemeToggleProps = {
className?: string;
@ -16,20 +28,19 @@ const ThemeToggle = ({ className }: ThemeToggleProps) => {
useEffect(() => setMounted(true), []);
if (!mounted) {
return (
<button className={styles.button} aria-hidden={true}>
<Button aria-hidden={true}>
<SunIcon className={className} />
</button>
</Button>
);
}
return (
<button
className={styles.button}
<Button
onClick={() => setTheme(resolvedTheme === "light" ? "dark" : "light")}
title={resolvedTheme === "light" ? "Toggle Dark Mode" : "Toggle Light Mode"}
>
{resolvedTheme === "light" ? <SunIcon className={className} /> : <MoonIcon className={className} />}
</button>
</Button>
);
};

View File

@ -1,26 +0,0 @@
.display {
height: 600px;
width: 100%;
max-width: 800px;
/* fix fuzziness: https://stackoverflow.com/a/13492784 */
image-rendering: optimizeSpeed;
image-rendering: -webkit-optimize-contrast;
image-rendering: crisp-edges;
image-rendering: pixelated;
-ms-interpolation-mode: nearest-neighbor;
}
.display div {
background: none !important;
}
.display div canvas {
cursor: default !important;
}
.terminal {
height: 400px;
width: 100%;
max-width: 700px;
}

View File

@ -2,8 +2,41 @@ import { useRef, useEffect, useState, memo } from "react";
import { useRouter } from "next/router";
import RFB from "@novnc/novnc/core/rfb.js";
import Terminal from "../Terminal/Terminal";
import { styled } from "../../lib/styles/stitches.config";
import styles from "./VNC.module.css";
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: "default !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",
}
);
const DOS = styled(Terminal, {
height: "400px",
width: "100%",
maxWidth: "700px",
});
export type VNCProps = {
server: string;
@ -104,10 +137,10 @@ const VNC = ({ server }: VNCProps) => {
return (
<>
<Terminal ref={terminalRef} className={styles.terminal} />
<DOS ref={terminalRef} />
{/* the VNC canvas is hidden until we've successfully connected to the socket */}
<div ref={screenRef} className={styles.display} style={{ display: "none" }} />
<Display ref={screenRef} style={{ display: "none" }} />
</>
);
};

View File

@ -1,14 +0,0 @@
.wrapper {
position: relative;
padding-top: 56.25%;
}
.wrapper > div {
position: absolute;
top: 0;
left: 0;
}
.wrapper video {
border-radius: var(--rounded-edge-radius);
}

View File

@ -1,8 +1,21 @@
import classNames from "classnames";
import ReactPlayer from "react-player/file";
import { styled } from "../../lib/styles/stitches.config";
import type { FilePlayerProps } from "react-player/file";
import styles from "./Video.module.css";
const Wrapper = styled("div", {
position: "relative",
paddingTop: "56.25%",
"& > div": {
position: "absolute",
top: 0,
left: 0,
},
"& video": {
borderRadius: "$rounded",
},
});
export type VideoProps = Partial<FilePlayerProps> & {
src: {
@ -59,7 +72,7 @@ const Video = ({ src, thumbnail, subs, autoplay, className, ...rest }: VideoProp
}
return (
<div className={classNames(styles.wrapper, className)}>
<Wrapper className={className}>
<ReactPlayer
width="100%"
height="100%"
@ -69,7 +82,7 @@ const Video = ({ src, thumbnail, subs, autoplay, className, ...rest }: VideoProp
config={config}
{...rest}
/>
</div>
</Wrapper>
);
};

View File

@ -1,9 +0,0 @@
.wallpaper {
width: 100%;
min-height: 400px;
}
.tile {
background-repeat: repeat;
background-position: center;
}

View File

@ -1,21 +1,43 @@
import { useEffect, useRef } from "react";
import classNames from "classnames";
import { styled } from "../../lib/styles/stitches.config";
import type { ComponentProps } from "react";
import type { VariantProps } from "@stitches/react";
import styles from "./Wallpaper.module.css";
const Wrapper = styled("main", {
width: "100%",
minHeight: "400px",
export type WallpaperProps = JSX.IntrinsicElements["main"] & {
variants: {
tile: {
true: {
backgroundRepeat: "repeat",
backgroundPosition: "center",
},
},
},
});
export type WallpaperProps = ComponentProps<typeof Wrapper> & {
image: string;
tile?: boolean;
};
const Wallpaper = ({ image, tile, className, ...rest }: WallpaperProps) => {
const bgRef = useRef<HTMLDivElement>(null);
const Wallpaper = ({ image, tile, ...rest }: WallpaperProps) => {
const bgRef = useRef<VariantProps<typeof Wrapper>>(null);
useEffect(() => {
// @ts-ignore
bgRef.current.style.backgroundImage = `url(${image})`;
}, []); // eslint-disable-line react-hooks/exhaustive-deps
return <main ref={bgRef} className={classNames(styles.wallpaper, tile && styles.tile, className)} {...rest} />;
return (
<Wrapper
tile={tile}
// @ts-ignore
ref={bgRef}
{...rest}
/>
);
};
export default Wallpaper;

View File

@ -1,15 +0,0 @@
.wrapper {
position: relative;
padding-top: 56.25%;
}
.wrapper > div {
position: absolute;
top: 0;
left: 0;
}
.wrapper :global(.react-player__preview),
.wrapper iframe {
border-radius: var(--rounded-edge-radius);
}

View File

@ -1,8 +1,21 @@
import classNames from "classnames";
import ReactPlayer from "react-player/youtube";
import { styled } from "../../lib/styles/stitches.config";
import type { YouTubePlayerProps } from "react-player/youtube";
import styles from "./YouTubeEmbed.module.css";
const Wrapper = styled("div", {
position: "relative",
paddingTop: "56.25%",
"& > div": {
position: "absolute",
top: 0,
left: 0,
},
"& .react-player__preview, & iframe": {
borderRadius: "$rounded",
},
});
export type YouTubeEmbedProps = Partial<YouTubePlayerProps> & {
id: string;
@ -10,7 +23,7 @@ export type YouTubeEmbedProps = Partial<YouTubePlayerProps> & {
};
const YouTubeEmbed = ({ id, className, ...rest }: YouTubeEmbedProps) => (
<div className={classNames(styles.wrapper, className)}>
<Wrapper className={className}>
<ReactPlayer
width="100%"
height="100%"
@ -19,7 +32,7 @@ const YouTubeEmbed = ({ id, className, ...rest }: YouTubeEmbedProps) => (
controls
{...rest}
/>
</div>
</Wrapper>
);
export default YouTubeEmbed;